Compare commits

..

32 Commits

Author SHA1 Message Date
Youwes09 e33464b05b Chore: Update to Version 0.9.1 (V2) 2026-04-27 21:21:08 -05:00
Youwes09 6f15e8fbc2 Chore: Update Bump & Tauri-Windows 2026-04-27 21:19:20 -05:00
Youwes09 86c6558bab Chore: Update Version to 0.9.1 2026-04-27 21:00:30 -05:00
Youwes09 c041f99c75 Feat: Download Queue Patching & UI Revisions (#54) 2026-04-27 14:40:47 -05:00
Youwes09 84c2a82c2c Feat: Touch Gestures (Pinch Zoom) for Reader (#29) 2026-04-27 13:31:10 -05:00
Youwes09 dc174bee4a Chore: Switched Zoom-Keybinds & Moku-Full SVG (#53) 2026-04-27 11:15:32 -05:00
Youwes09 72496a25e2 Feat: Hide Completed-Mangas in Default 2026-04-26 23:35:06 -05:00
Youwes09 8b074e4b97 Chore: Patch PKGBUILD for AUR (V1) 2026-04-26 22:41:34 -05:00
Youwes09 743f14f561 Chore: Build-Linux Workflow Debugging (V4) 2026-04-26 22:27:48 -05:00
Youwes09 d9ae94f0ff Chore: Build-Linux Workflow Debugging (V3) 2026-04-26 22:23:54 -05:00
Youwes09 045bcc5bc4 Chore: Build-Linux Workflow (V2) 2026-04-26 22:11:39 -05:00
Youwes09 4004a49cfb Chore: Patch for LinuxDeploy 2026-04-26 22:00:05 -05:00
Youwes09 ee72e345bd Chore: Change TauriBuild to MokuProject from moku_project 2026-04-26 21:52:11 -05:00
Youwes09 336ab0a24f Chore: Appimage-Workflow (V1) 2026-04-26 21:41:24 -05:00
Youwes09 c3b015f00f Chore: Change from Youwes09 to moku-project 2026-04-26 21:22:08 -05:00
Shozikan 22e3095cf5 Chore: Change Badge Theme in README 2026-04-26 20:51:48 -05:00
Shozikan 7bc2050971 Chore: Update README with Flatpak & Badges 2026-04-26 20:48:59 -05:00
Youwes09 d26f0b85e3 Feat: TrackingPanel + Tracking Re-design (WIP) 2026-04-26 14:28:45 -05:00
Youwes09 e8e6f18851 Fix: Library-Folder Truncation 2026-04-26 13:58:04 -05:00
Youwes09 50c5131477 Feat: Dual-Sync Tracking (#52) 2026-04-26 13:36:30 -05:00
Youwes09 c0efbba4df Feat: Automated Tracking + Proper Sync 2026-04-26 12:11:45 -05:00
Youwes09 361a145702 Chore: Update Theme-Default & Remove Light-Contrast 2026-04-26 00:29:31 -05:00
Youwes09 1c004d7e5c Chore: Re-Upload Moku-Home Image 2026-04-25 23:39:40 -05:00
Shozikan fb72e45817 Chore: Update README with new Image Layout 2026-04-25 23:38:46 -05:00
Youwes09 5c2e2b6866 Chore: Update Moku Images for 0.9.0 2026-04-25 23:33:59 -05:00
Shozikan 4b313512d4 Chore: Update README with Winget (Attribution included) 2026-04-25 23:24:30 -05:00
Youwes09 63258b2aa1 Feat: Update-All Extensions Button 2026-04-25 17:22:13 -05:00
Youwes09 b5f96a3a5c Feat: System-Default Based Custom Theme Switching (#45) 2026-04-25 15:54:21 -05:00
Youwes09 4eef03cbb1 Fix: Library Folder-Tab Adjustment Enhancement 2026-04-25 15:33:15 -05:00
Youwes09 f6118077fb Feat: Downloads Auto-Retry Button & Toast Enhancements 2026-04-25 15:17:18 -05:00
Youwes09 544792a7ad Fix: MacOS x86 Detection Logic (UNTESTED) 2026-04-25 09:45:34 -05:00
Youwes09 e063369dfb Fix: Flatpak Patches + Fix BulkMove Library 2026-04-25 09:41:11 -05:00
74 changed files with 10685 additions and 1696 deletions
+171
View File
@@ -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"
+2 -2
View File
@@ -167,7 +167,7 @@ jobs:
run: | run: |
# Wait for the Windows workflow to have created the draft release # Wait for the Windows workflow to have created the draft release
for i in $(seq 1 12); do for i in $(seq 1 12); do
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/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 if [ -n "$RELEASE_ID" ]; then break; fi
echo "Waiting for release to exist... attempt $i" echo "Waiting for release to exist... attempt $i"
sleep 15 sleep 15
@@ -184,7 +184,7 @@ jobs:
local file="$1" local file="$1"
local name="$2" local name="$2"
echo "Uploading $name..." 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) ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
+8 -7
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: "Version to build (e.g. 0.4.0)" description: "Version to build (e.g. 0.9.0)"
required: true required: true
permissions: permissions:
@@ -134,12 +134,15 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id') RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases" \
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
if [ -n "$RELEASE_ID" ]; then if [ -n "$RELEASE_ID" ]; then
echo "Deleting existing draft release $RELEASE_ID" echo "Deleting existing draft release $RELEASE_ID"
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID" curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
# Also delete the tag so tauri-action can recreate it "https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/git/refs/tags/v${{ github.event.inputs.version }}" curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
echo "Deleted draft release and tag" echo "Deleted draft release and tag"
else else
echo "No existing draft release found" echo "No existing draft release found"
@@ -149,8 +152,6 @@ jobs:
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with: with:
tagName: v${{ github.event.inputs.version }} tagName: v${{ github.event.inputs.version }}
releaseName: Moku v${{ github.event.inputs.version }} releaseName: Moku v${{ github.event.inputs.version }}
+20 -24
View File
@@ -1,10 +1,10 @@
pkgname=moku pkgname=moku
pkgver=0.5.0 pkgver=0.9.1
pkgrel=1 pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server" pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64') arch=('x86_64')
url="https://github.com/Youwes09/Moku" url="https://github.com/moku-project/Moku"
license=('Apache 2.0') license=('Apache-2.0')
depends=( depends=(
'webkit2gtk-4.1' 'webkit2gtk-4.1'
'gtk3' 'gtk3'
@@ -18,13 +18,13 @@ makedepends=(
'pnpm' 'pnpm'
) )
source=( source=(
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz" "$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar" "Suwayomi-Server-v2.1.1867.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867.jar"
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz" )
sha256sums=(
'SKIP'
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
) )
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
prepare() { prepare() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
@@ -34,7 +34,6 @@ prepare() {
build() { build() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
pnpm build pnpm build
tar -czf packaging/frontend-dist.tar.gz -C dist .
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \ TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
--release \ --release \
--manifest-path src-tauri/Cargo.toml --manifest-path src-tauri/Cargo.toml
@@ -46,10 +45,7 @@ package() {
install -Dm755 src-tauri/target/release/moku \ install -Dm755 src-tauri/target/release/moku \
"$pkgdir/usr/bin/moku" "$pkgdir/usr/bin/moku"
install -dm755 "$pkgdir/usr/lib/moku/jre" install -Dm644 "$srcdir/Suwayomi-Server-v2.1.1867.jar" \
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
install -Dm644 "$srcdir/suwayomi-server.jar" \
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar" "$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf" install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
@@ -66,7 +62,7 @@ server.maxSourcesInParallel = 6
server.extensionRepos = [] server.extensionRepos = []
EOF EOF
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF' install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'EOF'
#!/bin/sh #!/bin/sh
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
@@ -90,7 +86,7 @@ unset WAYLAND_DISPLAY
export _JAVA_OPTIONS="-Djava.awt.headless=true" export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true" export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
exec /usr/lib/moku/jre/bin/java \ exec java \
-Djava.awt.headless=true \ -Djava.awt.headless=true \
-Dapple.awt.UIElement=true \ -Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \ -Dsun.java2d.noddraw=true \
@@ -99,16 +95,16 @@ exec /usr/lib/moku/jre/bin/java \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar -jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
EOF EOF
install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \ install -Dm644 packaging/io.github.moku_project.Moku.desktop \
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop" "$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
install -Dm644 src-tauri/icons/32x32.png \ install -Dm644 src-tauri/icons/32x32.png \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.app.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png"
install -Dm644 src-tauri/icons/128x128.png \ install -Dm644 src-tauri/icons/128x128.png \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.app.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png"
install -Dm644 src-tauri/icons/128x128@2x.png \ install -Dm644 src-tauri/icons/128x128@2x.png \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \ install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml" "$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
+48 -26
View File
@@ -4,10 +4,10 @@
<div align="center"> <div align="center">
[![release](https://img.shields.io/github/v/release/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest) [![Release](https://www.shieldcn.dev/github/release/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/releases/latest)
[![downloads](https://img.shields.io/github/downloads/Youwes09/Moku/total?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest) [![Last Commit](https://www.shieldcn.dev/github/last-commit/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/commits/main)
[![license](https://img.shields.io/github/license/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](./LICENSE) [![Stars](https://www.shieldcn.dev/github/stars/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku)
[![discord](https://img.shields.io/discord/1485151759511978077?style=flat&color=a8c4a8&labelColor=151515&label=discord)](https://discord.gg/x97hj8zR72) [![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=outline&size=default)](https://discord.gg/x97hj8zR72)
</div> </div>
@@ -20,16 +20,20 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
## Screenshots ## Screenshots
<div align="center"> <div align="center">
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" /> <img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="TagSearch" />
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
</div> </div>
<div align="center"> <div align="center">
<a href="docs/screenshots">View all screenshots →</a> <img src="docs/screenshots/Moku-Search.png" width="49%" alt="Search" />
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="Tag Search" />
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
<img src="docs/screenshots/Moku-Downloads.png" width="49%" alt="Downloads" />
<img src="docs/screenshots/Moku-ReaderSettings.png" width="49%" alt="Reader Settings" />
</div>
<div align="center">
<a href="docs/screenshots" style="color: #a8c4a8;">View all screenshots →</a>
</div> </div>
--- ---
@@ -43,7 +47,6 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
- **Extension support** — install and manage Suwayomi extensions directly from the app - **Extension support** — install and manage Suwayomi extensions directly from the app
- **Download management** — queue and monitor chapter downloads with progress toasts - **Download management** — queue and monitor chapter downloads with progress toasts
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail) - **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General - **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
- **Auto-start server** — optionally launch Suwayomi in the background on startup - **Auto-start server** — optionally launch Suwayomi in the background on startup
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more - **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
@@ -54,36 +57,55 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
## Installation ## Installation
### Flatpak (Linux, recommended) <div align="center">
![Runs on Windows](https://www.shieldcn.dev/badge/Runs%20on-Windows-0078D4.svg?logo=windows&logoColor=fff)
![Runs on Linux](https://www.shieldcn.dev/badge/Runs%20on-Linux-FCC624.svg?logo=linux&logoColor=000)
![Runs on MacOS](https://www.shieldcn.dev/badge/Runs%20on-MacOS-000000.svg?mode=light&logo=apple&logoColor=fff)
</div>
### Windows
**winget:**
```powershell
winget install Moku.Moku
```
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
### Linux (Flatpak, recommended)
Suwayomi-Server and a bundled JRE are included — no separate install needed. Suwayomi-Server and a bundled JRE are included — no separate install needed.
```bash ```bash
flatpak install moku.flatpak flatpak install io.github.moku_app.Moku
flatpak run dev.moku.app
``` ```
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
```bash
flatpak install moku.flatpak
```
### Nix ### Nix
```bash ```bash
nix run github:Youwes09/Moku nix run github:moku-project/Moku
``` ```
Add to your flake: Add to your flake:
```nix ```nix
inputs.moku.url = "github:Youwes09/Moku"; inputs.moku.url = "github:moku-project/Moku";
``` ```
### Windows
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
### macOS ### macOS
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
> **Note:** Builds are ad-hoc signed. On first launch you may need to run: > **Note:** Builds are ad-hoc signed. On first launch you may need to run:
> ```bash > ```bash
@@ -105,7 +127,7 @@ You can point Moku at any Suwayomi instance — local or remote — via **Settin
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/). **Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
```bash ```bash
git clone https://github.com/Youwes09/Moku git clone https://github.com/moku-project/Moku
cd Moku cd Moku
pnpm install pnpm install
pnpm tauri:dev pnpm tauri:dev
@@ -136,7 +158,7 @@ pnpm tauri:dev
Questions, feedback, or just want to hang out — join the Discord. Questions, feedback, or just want to hang out — join the Discord.
[![Discord](https://img.shields.io/discord/1485151759511978077?style=for-the-badge&color=a8c4a8&labelColor=151515&label=Join+the+Discord)](https://discord.gg/x97hj8zR72) [![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=secondary&size=large)](https://discord.gg/x97hj8zR72)
--- ---
@@ -148,4 +170,4 @@ Distributed under the [Apache 2.0 License](./LICENSE).
## Disclaimer ## 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.
+4 -4
View File
@@ -25,14 +25,14 @@ In-Progress:
- Working on 3D Display Cards - Working on 3D Display Cards
- Add Flathub Support (Pending Video) - Add Flathub Support (Pending Video)
- QOL Animations & Revamps
- Tracking Revamp
- Completely Revamp Tracking
- Fix Tracking Login - Fix Tracking Login
- Pasting OAuth URL is not User-Friendly, Look for Alternatives - 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: Notes from last time:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 947 KiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 MiB

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 940 KiB

+81 -45
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }: perSystem = { system, lib, ... }:
let let
version = "0.9.0"; version = "0.9.1";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
@@ -149,43 +149,27 @@ EOF
bumpScript = pkgs.writeShellApplication { bumpScript = pkgs.writeShellApplication {
name = "moku-bump"; name = "moku-bump";
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ]; runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain
nodejs_22 pnpm
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ])) ];
text = '' text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; } [[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
VERSION="$1" VERSION="$1"
REPO="$(git rev-parse --show-toplevel)" REPO="$(git rev-parse --show-toplevel)"
echo " Bumping version fields to $VERSION "
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \ sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
"$REPO/src-tauri/tauri.conf.json" "$REPO/src-tauri/tauri.conf.json"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \ sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
"$REPO/src-tauri/Cargo.toml" "$REPO/src-tauri/Cargo.toml"
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \ sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
"$REPO/flake.nix" "$REPO/flake.nix"
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
echo "Done"
echo " Regenerating Cargo.lock "
(cd "$REPO/src-tauri" && cargo generate-lockfile) (cd "$REPO/src-tauri" && cargo generate-lockfile)
echo "Bumped to $VERSION"
'';
};
flatpakScript = pkgs.writeShellApplication {
name = "moku-flatpak";
runtimeInputs = with pkgs; [
gnused coreutils git
nodejs_22 pnpm
appstream flatpak-builder flatpak
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
echo " Bumping versions "
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
"$REPO/src-tauri/tauri.conf.json"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
"$REPO/src-tauri/Cargo.toml"
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
"$REPO/flake.nix"
echo "Done" echo "Done"
echo " Building frontend " echo " Building frontend "
@@ -199,7 +183,15 @@ EOF
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}') FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
echo "sha256: $FRONTEND_SHA" echo "sha256: $FRONTEND_SHA"
echo " Patching manifest sha256 " echo " Regenerating cargo-sources.json "
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
"$REPO/src-tauri/Cargo.lock" \
-o "$REPO/packaging/cargo-sources.json"
echo "Done"
echo " Patching flatpak manifest (version + frontend sha256) "
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF' python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
import re, sys import re, sys
path, sha = sys.argv[1], sys.argv[2] path, sha = sys.argv[1], sys.argv[2]
@@ -213,29 +205,70 @@ EOF
PYEOF PYEOF
echo "Done" echo "Done"
echo " Regenerating cargo-sources.json " echo ""
python3 "$REPO/packaging/flatpak-cargo-generator.py" \ echo "Bumped to v$VERSION"
"$REPO/src-tauri/Cargo.lock" \ echo ""
-o "$REPO/packaging/cargo-sources.json" echo "Commit field in the flatpak manifest still points to the old tag."
echo "After pushing the tag, run:"
echo " nix run .#post-tag-bump -- $VERSION"
'';
};
postTagBumpScript = pkgs.writeShellApplication {
name = "moku-post-tag-bump";
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
PKGBUILD="$REPO/PKGBUILD"
echo " Resolving commit for v$VERSION "
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
| awk '{print $1}')
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
echo "commit: $COMMIT"
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
echo "Done" echo "Done"
echo " Building flatpak " echo " Fetching PKGBUILD tarball sha256 "
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
echo "Done"
echo ""
echo "post-tag-bump complete for v$VERSION"
'';
};
flatpakScript = pkgs.writeShellApplication {
name = "moku-flatpak";
runtimeInputs = with pkgs; [
gnused coreutils git
appstream flatpak-builder flatpak
];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
echo " Building flatpak for v$VERSION "
rm -rf "$REPO/build-dir" "$REPO/repo" rm -rf "$REPO/build-dir" "$REPO/repo"
flatpak-builder \ flatpak-builder \
--repo="$REPO/repo" \ --repo="$REPO/repo" \
--force-clean \ --force-clean \
"$REPO/build-dir" \ "$REPO/build-dir" \
"$MANIFEST" "$MANIFEST"
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.Youwes09.Moku flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
rm -rf "$REPO/build-dir" "$REPO/repo" rm -rf "$REPO/build-dir" "$REPO/repo"
echo "moku.flatpak created"
echo "" echo ""
echo "Done v$VERSION" echo "moku.flatpak created v$VERSION"
echo " -> $REPO/moku.flatpak"
echo ""
echo "After pushing the tag, run:"
echo " nix run .#pkgbuild-bump -- $VERSION"
''; '';
}; };
@@ -249,7 +282,7 @@ EOF
PKGBUILD="$REPO/PKGBUILD" PKGBUILD="$REPO/PKGBUILD"
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; } [[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v$VERSION.tar.gz" TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
echo "Fetching tarball sha256..." echo "Fetching tarball sha256..."
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}') TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
@@ -279,6 +312,7 @@ EOF
default = { type = "app"; program = "${moku}/bin/moku"; }; default = { type = "app"; program = "${moku}/bin/moku"; };
moku = { type = "app"; program = "${moku}/bin/moku"; }; moku = { type = "app"; program = "${moku}/bin/moku"; };
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; }; bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
post-tag-bump = { type = "app"; program = "${postTagBumpScript}/bin/moku-post-tag-bump"; };
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; }; flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; }; pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; }; tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
@@ -300,6 +334,7 @@ EOF
suwayomi-server suwayomi-server
cloudflared cloudflared
xdg-utils xdg-utils
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
]; ];
shellHook = '' shellHook = ''
export NO_STRIP=true export NO_STRIP=true
@@ -308,10 +343,11 @@ EOF
echo "Moku dev shell pnpm install && pnpm tauri:dev" echo "Moku dev shell pnpm install && pnpm tauri:dev"
echo "" echo ""
echo "Release:" echo "Release workflow:"
echo " nix run .#bump -- <ver> bump versions only" echo " nix run .#bump -- <ver> bump all versions + rebuild artifacts"
echo " nix run .#flatpak -- <ver> full flatpak build" echo " git commit && git tag && git push"
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)" echo " nix run .#post-tag-bump -- <ver> patch manifest commit + PKGBUILD sha"
echo " nix run .#flatpak -- <ver> build moku.flatpak"
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)" echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
''; '';
}; };
@@ -1,4 +1,4 @@
app-id: io.github.Youwes09.Moku app-id: io.github.moku_project.Moku
runtime: org.gnome.Platform runtime: org.gnome.Platform
runtime-version: '48' runtime-version: '48'
sdk: org.gnome.Sdk sdk: org.gnome.Sdk
@@ -171,19 +171,19 @@ modules:
- tar -xzf frontend-dist.tar.gz - tar -xzf frontend-dist.tar.gz
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml - . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
- install -Dm755 src-tauri/target/release/moku /app/bin/moku - install -Dm755 src-tauri/target/release/moku /app/bin/moku
- install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop - install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png - install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.png - install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.png - install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
- install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml - install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
sources: sources:
- type: git - type: git
url: https://github.com/Youwes09/Moku.git url: https://github.com/moku-project/Moku.git
tag: v0.8.0 tag: v0.9.1
commit: c573c543187cbd1ca1455b25d6bce0fc62666341 commit: 514910667b0d6e375569a48fb7cef11411d30fbd
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: d547893e1b76f1678df131d46b0964e9ef34e54e8571d5c435a22cef7316f75a sha256: ce773b63c625448df8e128508b46e7e84d2e5cdb1f2b65a6a03f52a4e350b0bf
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
Name=Moku Name=Moku
Comment=Manga reader powered by Suwayomi Comment=Manga reader powered by Suwayomi
Exec=moku Exec=moku
Icon=io.github.Youwes09.Moku Icon=io.github.moku_project.Moku
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Graphics;Viewer; Categories=Graphics;Viewer;
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application"> <component type="desktop-application">
<id>io.github.Youwes09.Moku</id> <id>io.github.moku_project.Moku</id>
<metadata_license>MIT</metadata_license> <metadata_license>MIT</metadata_license>
<project_license>MIT</project_license> <project_license>MIT</project_license>
@@ -19,30 +19,30 @@
</p> </p>
</description> </description>
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable> <launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
<url type="homepage">https://github.com/Youwes09/Moku</url> <url type="homepage">https://github.com/moku-project/Moku</url>
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url> <url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
<screenshots> <screenshots>
<screenshot type="default"> <screenshot type="default">
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image> <image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
<caption>Home screen showing your manga library</caption> <caption>Home screen showing your manga library</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image> <image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
<caption>Built-in manga reader</caption> <caption>Built-in manga reader</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image> <image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
<caption>Discover new manga across hundreds of sources</caption> <caption>Discover new manga across hundreds of sources</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image> <image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
<caption>Download manager</caption> <caption>Download manager</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image> <image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
<caption>Settings</caption> <caption>Settings</caption>
</screenshot> </screenshot>
</screenshots> </screenshots>
@@ -54,11 +54,16 @@
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<release version="0.8.0" date="2025-04-01"> <release version="0.9.0" date="2025-04-01">
<description> <description>
<p>Latest release with improved stability and UI refinements.</p> <p>Latest release with improved stability and UI refinements.</p>
</description> </description>
</release> </release>
<release version="0.8.0" date="2025-04-01">
<description>
<p>Old release with improved stability and UI refinements.</p>
</description>
</release>
<release version="0.4.0" date="2025-03-22"> <release version="0.4.0" date="2025-03-22">
<description> <description>
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p> <p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
+13 -13
View File
@@ -1701,9 +1701,9 @@ dependencies = [
[[package]] [[package]]
name = "idna_adapter" name = "idna_adapter"
version = "1.2.1" version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [ dependencies = [
"icu_normalizer", "icu_normalizer",
"icu_properties", "icu_properties",
@@ -2092,7 +2092,7 @@ dependencies = [
[[package]] [[package]]
name = "moku" name = "moku"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -2811,9 +2811,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]] [[package]]
name = "plist" name = "plist"
version = "1.8.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.14.0", "indexmap 2.14.0",
@@ -2961,9 +2961,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -3310,9 +3310,9 @@ dependencies = [
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.2" version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
@@ -4202,7 +4202,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest 0.13.2", "reqwest 0.13.3",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -4526,13 +4526,13 @@ dependencies = [
[[package]] [[package]]
name = "tauri-winres" name = "tauri-winres"
version = "0.3.5" version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
dependencies = [ dependencies = [
"dunce", "dunce",
"embed-resource", "embed-resource",
"toml 0.9.12+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
] ]
[[package]] [[package]]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.9.0" version = "0.9.1"
edition = "2021" edition = "2021"
[lib] [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"
+3 -3
View File
@@ -269,7 +269,7 @@ fn suwayomi_data_dir() -> PathBuf {
{ {
dirs::data_dir() dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"))) .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("io.github.Youwes09.Moku.app/tachidesk") .join("io.github.moku_project.Moku.app/tachidesk")
} }
#[cfg(not(any(target_os = "windows", target_os = "macos")))] #[cfg(not(any(target_os = "windows", target_os = "macos")))]
{ {
@@ -589,7 +589,7 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let resp = client let resp = client
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30") .get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
.send() .send()
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -635,7 +635,7 @@ async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Resu
.build() .build()
.map_err(|e| e.to_string())?; .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())?; let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag)); return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
+4 -10
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Moku", "productName": "Moku",
"version": "0.9.0", "version": "0.9.1",
"identifier": "io.github.Youwes09.Moku.app", "identifier": "io.github.MokuProject.Moku",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
"beforeBuildCommand": "pnpm build" "beforeBuildCommand": "pnpm build"
@@ -27,9 +27,7 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": [ "targets": ["nsis"],
"nsis"
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@@ -49,10 +47,6 @@
"plugins": { "plugins": {
"shell": { "shell": {
"open": true "open": true
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
"endpoints": []
} }
} }
} }
+13
View File
@@ -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"
}
}
}
-12
View File
@@ -1,20 +1,8 @@
{ {
"bundle": { "bundle": {
"createUpdaterArtifacts": true,
"resources": [ "resources": [
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar", "binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
"binaries/suwayomi-bundle/jre/**/*" "binaries/suwayomi-bundle/jre/**/*"
] ]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
"endpoints": [
"https://github.com/Youwes09/Moku/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
} }
} }
+22
View File
@@ -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

+8 -2
View File
@@ -12,6 +12,12 @@ function getServerBase(): string {
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567"; return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
} }
function timeoutSignal(ms: number): AbortSignal {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
function basicHeader(user: string, pass: string): Record<string, string> { function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
} }
@@ -34,7 +40,7 @@ export async function loginBasic(user: string, pass: string): Promise<void> {
method: "POST", credentials: "omit", method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) }, headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
body: JSON.stringify({ query: "{ __typename }" }), body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(5000), signal: timeoutSignal(5000),
}); });
if (!res.ok) throw new Error(`Authentication failed (${res.status})`); if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass }); updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
@@ -58,7 +64,7 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
const res = await fetch(`${base}/api/graphql`, { const res = await fetch(`${base}/api/graphql`, {
method: "POST", credentials: "omit", headers, method: "POST", credentials: "omit", headers,
body: JSON.stringify({ query: "{ __typename }" }), body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(5000), signal: timeoutSignal(5000),
}); });
if (res.ok) return "ok"; if (res.ok) return "ok";
if (res.status === 401) { if (res.status === 401) {
+32 -1
View File
@@ -1,6 +1,8 @@
import { store } from "@store/state.svelte"; import { store, updateSettings } from "@store/state.svelte";
let themeStyleEl: HTMLStyleElement | null = null; let themeStyleEl: HTMLStyleElement | null = null;
let mediaQuery: MediaQueryList | null = null;
let mediaHandler: (() => void) | null = null;
export function applyTheme() { export function applyTheme() {
const themeId = store.settings.theme ?? "dark"; const themeId = store.settings.theme ?? "dark";
@@ -34,3 +36,32 @@ export function applyTheme() {
themeStyleEl.textContent = css; themeStyleEl.textContent = css;
document.documentElement.setAttribute("data-theme", "custom"); document.documentElement.setAttribute("data-theme", "custom");
} }
function applySystemTheme(dark: boolean) {
const themeId = dark
? (store.settings.systemThemeDark ?? "dark")
: (store.settings.systemThemeLight ?? "light");
updateSettings({ theme: themeId });
}
export function mountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
}
if (!store.settings.systemThemeSync) return;
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
mediaQuery.addEventListener("change", mediaHandler);
applySystemTheme(mediaQuery.matches);
}
export function unmountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
mediaQuery = null;
}
}
@@ -1,4 +1,4 @@
[data-theme="high-contrast"] { [data-theme="dark"] {
--bg-void: #000000; --bg-void: #000000;
--bg-base: #080808; --bg-base: #080808;
--bg-surface: #0d0d0d; --bg-surface: #0d0d0d;
@@ -22,4 +22,4 @@
--accent-muted: #1e2e1e; --accent-muted: #1e2e1e;
--accent-fg: #bcd8bc; --accent-fg: #bcd8bc;
--accent-bright: #9fcf9f; --accent-bright: #9fcf9f;
} }
+3 -3
View File
@@ -1,5 +1,5 @@
@import "./high-contrast.css"; @import "./original.css";
@import "./light-contrast.css"; @import "./dark.css";
@import "./light.css"; @import "./light.css";
@import "./midnight.css"; @import "./midnight.css";
@import "./warm.css"; @import "./warm.css";
-29
View File
@@ -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;
}
+22 -25
View File
@@ -1,32 +1,29 @@
[data-theme="light"] { [data-theme="light"] {
--bg-void: #e8e6e2; --bg-void: #d8d4ce;
--bg-base: #eeece8; --bg-base: #e2deda;
--bg-surface: #f4f2ee; --bg-surface: #ece8e2;
--bg-raised: #faf8f4; --bg-raised: #f5f2ec;
--bg-overlay: #ffffff; --bg-overlay: #ffffff;
--bg-subtle: #f0ede8; --bg-subtle: #e4e0d8;
--border-dim: #dedad4; --border-dim: #c4c0b8;
--border-base: #d0ccc6; --border-base: #b0aca4;
--border-strong: #bbb6ae; --border-strong: #989490;
--border-focus: #5a7a5a; --border-focus: #3a5a3a;
--text-primary: #1a1916; --text-primary: #080806;
--text-secondary: #2e2c28; --text-secondary: #181612;
--text-muted: #5a5750; --text-muted: #38342e;
--text-faint: #9a9890; --text-faint: #706c64;
--text-disabled: #c8c4bc; --text-disabled: #b0aca4;
--accent: #4a724a; --accent: #2a5a2a;
--accent-dim: #c8dcc8; --accent-dim: #b0ccb0;
--accent-muted: #deeade; --accent-muted: #c8dcc8;
--accent-fg: #2a5a2a; --accent-fg: #183818;
--accent-bright: #3a6a3a; --accent-bright: #1e4e1e;
--color-error: #a03030; --color-error: #8a1a1a;
--color-error-bg: #fce8e8; --color-error-bg: #f8e0e0;
--color-success: #2a6a2a; --color-read: #e0dcd4;
--color-info: #2a4a7a;
--color-info-bg: #e8eef8;
--color-read: #e8e4dc;
} }
+31
View File
@@ -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;
}
+3 -1
View File
@@ -32,4 +32,6 @@
--dot-active: var(--accent); --dot-active: var(--accent);
--dot-inactive: var(--text-faint); --dot-inactive: var(--text-faint);
}
--bg-image: none;
}
@@ -1,39 +1,22 @@
<script lang="ts"> <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 Thumbnail from "@shared/manga/Thumbnail.svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte";
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
import type { DownloadQueueItem } from "@types/index"; import type { DownloadQueueItem } from "@types/index";
import { pageProgress } from "../lib/downloadQueue"; import { pageProgress } from "../lib/downloadQueue";
interface Props { interface Props {
item: DownloadQueueItem; item: DownloadQueueItem;
index: number; isActive: boolean;
isActive: boolean; isRemoving: boolean;
isFirst: boolean; isSelected: boolean;
isLast: boolean; onRemove: (chapterId: number) => void;
isRemoving: boolean; onRetry: (chapterId: number) => void;
isSelected: boolean; onSelect: (chapterId: number, e: MouseEvent) => void;
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;
} }
const { const {
item, index, isActive, isFirst, isLast, isRemoving, item, isActive, isRemoving, isSelected,
isSelected, selectedCount, selectedErrorCount, batchWorking, onRemove, onRetry, onSelect,
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge, onClearSelect,
}: Props = $props(); }: Props = $props();
const manga = $derived(item.chapter.manga); const manga = $derived(item.chapter.manga);
@@ -41,144 +24,6 @@
const prog = $derived(pageProgress(item.progress, pages)); const prog = $derived(pageProgress(item.progress, pages));
const isError = $derived(item.state === "ERROR"); const isError = $derived(item.state === "ERROR");
const pct = $derived(Math.round(item.progress * 100)); const pct = $derived(Math.round(item.progress * 100));
let menuX = $state(0);
let menuY = $state(0);
let menuOpen = $state(false);
function openMenu(e: MouseEvent) {
e.preventDefault();
menuX = e.clientX;
menuY = e.clientY;
menuOpen = true;
}
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let touchMoved = false;
function onTouchStart(e: TouchEvent) {
touchMoved = false;
const touch = e.touches[0];
longPressTimer = setTimeout(() => {
longPressTimer = null;
if (touchMoved) return;
if (selectedCount === 0) {
onSelect(item.chapter.id, { shiftKey: false, ctrlKey: false, metaKey: false });
} else {
menuX = touch.clientX;
menuY = touch.clientY;
menuOpen = true;
}
}, 500);
}
function onTouchMove() {
touchMoved = true;
cancelLongPress();
}
function cancelLongPress() {
if (longPressTimer !== null) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
const menuItems = $derived.by<MenuEntry[]>(() => {
const inBatch = isSelected && selectedCount > 1;
const entries: MenuEntry[] = [];
if (inBatch) {
entries.push({
label: `Move 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> </script>
<div <div
@@ -188,10 +33,6 @@
class:row-selected={isSelected} class:row-selected={isSelected}
class:row-removing={isRemoving} class:row-removing={isRemoving}
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }} onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
oncontextmenu={openMenu}
ontouchstart={onTouchStart}
ontouchend={cancelLongPress}
ontouchmove={onTouchMove}
> >
{#if manga?.thumbnailUrl} {#if manga?.thumbnailUrl}
<div class="thumb"> <div class="thumb">
@@ -229,12 +70,6 @@
</button> </button>
{/if} {/if}
{#if !isActive} {#if !isActive}
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "up"); }} disabled={isFirst} title="Move up">
<ArrowUp size={11} weight="light" />
</button>
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "down"); }} disabled={isLast} title="Move down">
<ArrowDown size={11} weight="light" />
</button>
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove"> <button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if} {#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button> </button>
@@ -243,10 +78,6 @@
</div> </div>
</div> </div>
{#if menuOpen}
<ContextMenu x={menuX} y={menuY} items={menuItems} onClose={() => (menuOpen = false)} />
{/if}
<style> <style>
.row { .row {
display: flex; display: flex;
@@ -263,118 +94,33 @@
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
.row:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
.row.row-active { border-color: var(--accent-dim); } .row.row-active { border-color: var(--accent-dim); }
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); } .row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); } .row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
.row.row-removing { opacity: 0.4; pointer-events: none; } .row.row-removing { opacity: 0.4; pointer-events: none; }
.thumb { .thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
width: 36px;
height: 54px;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-overlay);
flex-shrink: 0;
border: 1px solid var(--border-dim);
}
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; } :global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
.info { .info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
min-width: 0;
}
.manga-title { .manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
font-size: var(--text-sm); .chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
font-weight: var(--weight-medium);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chapter-name { .progress-row { display: flex; align-items: center; gap: var(--sp-2); }
font-size: var(--text-xs); .progress-wrap { flex: 1; height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
color: var(--text-muted); .progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; opacity: 0.5; }
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-row {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.progress-wrap {
flex: 1;
height: 2px;
background: var(--border-base);
border-radius: var(--radius-full);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.4s ease;
opacity: 0.5;
}
.row-active .progress-bar { opacity: 1; } .row-active .progress-bar { opacity: 1; }
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; } .progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
.pages-label { .row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
font-family: var(--font-ui); .state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
white-space: nowrap;
}
.row-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-1);
flex-shrink: 0;
}
.state-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.state-label.state-error { color: var(--color-error); opacity: 0.8; } .state-label.state-error { color: var(--color-error); opacity: 0.8; }
.actions { .actions { display: flex; align-items: center; gap: 2px; }
display: flex; .action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base), background var(--t-base); }
align-items: center;
gap: 2px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--t-base), background var(--t-base);
}
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); } .action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
.action-btn:disabled { opacity: 0.25; cursor: default; } .action-btn:disabled { opacity: 0.25; cursor: default; }
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); } .action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
@@ -4,33 +4,22 @@
import type { DownloadQueueItem } from "@types/index"; import type { DownloadQueueItem } from "@types/index";
interface Props { interface Props {
queue: DownloadQueueItem[]; queue: DownloadQueueItem[];
loading: boolean; loading: boolean;
isRunning: boolean; isRunning: boolean;
dequeueing: Set<number>; dequeueing: Set<number>;
selected: Set<number>; selected: Set<number>;
batchWorking: boolean; onRemove: (chapterId: number) => void;
onRemove: (chapterId: number) => void; onRetry: (chapterId: number) => void;
onRetry: (chapterId: number) => void; onReorder: (chapterId: number, dir: "up" | "down") => void;
onReorder: (chapterId: number, dir: "up" | "down") => void; onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void; onSelect: (chapterId: number, e: MouseEvent) => void;
onSelect: (chapterId: number, e: MouseEvent) => void;
onClearSelect: () => void;
onBatchRemove: () => void;
onBatchRetry: () => void;
onBatchReorder: (dir: "up" | "down") => void;
onBatchReorderEdge: (edge: "top" | "bottom") => void;
} }
const { const {
queue, loading, isRunning, dequeueing, selected, batchWorking, queue, loading, isRunning, dequeueing, selected,
onRemove, onRetry, onReorder, onReorderEdge, onSelect, onClearSelect, onRemove, onRetry, onReorder, onReorderEdge, onSelect,
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge,
}: Props = $props(); }: Props = $props();
const selectedErrorCount = $derived(
queue.filter((i) => selected.has(i.chapter.id) && i.state === "ERROR").length,
);
</script> </script>
{#if loading} {#if loading}
@@ -41,117 +30,23 @@
<div class="empty">Queue is empty.</div> <div class="empty">Queue is empty.</div>
{:else} {:else}
<div class="list"> <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)} {#each queue as item, i (item.chapter.id)}
<DownloadItem <DownloadItem
{item} {item}
index={i}
isActive={i === 0 && isRunning} isActive={i === 0 && isRunning}
isFirst={i === 0}
isLast={i === queue.length - 1}
isRemoving={dequeueing.has(item.chapter.id)} isRemoving={dequeueing.has(item.chapter.id)}
isSelected={selected.has(item.chapter.id)} isSelected={selected.has(item.chapter.id)}
selectedCount={selected.size}
{selectedErrorCount}
{batchWorking}
{onRemove} {onRemove}
{onRetry} {onRetry}
{onReorder} {onReorder}
{onReorderEdge} {onReorderEdge}
{onSelect} {onSelect}
{onClearSelect}
{onBatchRemove}
{onBatchRetry}
{onBatchReorder}
{onBatchReorderEdge}
/> />
{/each} {/each}
</div> </div>
{/if} {/if}
<style> <style>
.list { .list { display: flex; flex-direction: column; gap: var(--sp-2); }
display: flex; .empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
flex-direction: column;
gap: var(--sp-2);
}
.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);
}
</style> </style>
+101 -110
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash } from "phosphor-svelte"; import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash, Repeat } from "phosphor-svelte";
import { ArrowLineUp, ArrowLineDown, X, CaretUp, CaretDown } from "phosphor-svelte";
import DownloadQueue from "./DownloadQueue.svelte"; import DownloadQueue from "./DownloadQueue.svelte";
import { downloadStore } from "../store/downloadState.svelte"; import { downloadStore } from "../store/downloadState.svelte";
import { formatEta } from "../lib/downloadQueue"; import { formatEta } from "../lib/downloadQueue";
@@ -10,6 +11,11 @@
}); });
let selectAnchor = $state<number | null>(null); let selectAnchor = $state<number | null>(null);
let moveBy = $state(1);
const selectedErrorCount = $derived(
downloadStore.queue.filter((i) => downloadStore.selected.has(i.chapter.id) && i.state === "ERROR").length,
);
function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) { function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) {
const ctrl = e.ctrlKey || e.metaKey; const ctrl = e.ctrlKey || e.metaKey;
@@ -39,12 +45,25 @@
selectAnchor = null; selectAnchor = null;
} }
} }
function clearSelection() {
downloadStore.clearSelection();
selectAnchor = null;
}
</script> </script>
<div class="root"> <div class="root">
<div class="header"> <div class="header">
<h1 class="heading">Downloads</h1> <h1 class="heading">Downloads</h1>
<div class="header-actions"> <div class="header-actions">
<button
class="icon-btn"
class:active={downloadStore.autoRetryEnabled}
onclick={() => downloadStore.toggleAutoRetry()}
title={downloadStore.autoRetryEnabled ? "Disable auto-retry" : "Enable auto-retry"}
>
<Repeat size={14} weight="regular" />
</button>
{#if downloadStore.hasErrored} {#if downloadStore.hasErrored}
<button <button
class="icon-btn" class="icon-btn"
@@ -95,147 +114,119 @@
</div> </div>
</div> </div>
<div class="content" onclick={handleClickOff}> <div class="bar-wrap">
<div class="status-bar"> <div class="status-bar" onclick={handleClickOff} role="presentation">
<div class="status-dot" class:active={downloadStore.isRunning}></div> <div class="status-dot" class:active={downloadStore.isRunning}></div>
<span class="status-text"> <span class="status-text">
{downloadStore.togglingPlay {downloadStore.togglingPlay
? (downloadStore.isRunning ? "Pausing…" : "Starting…") ? (downloadStore.isRunning ? "Pausing…" : "Starting…")
: downloadStore.isRunning ? "Downloading" : "Paused"} : downloadStore.isRunning ? "Downloading" : "Paused"}
</span> </span>
<div class="status-right"> {#if downloadStore.selected.size > 0}
{#if downloadStore.isRunning && downloadStore.eta !== null} <div class="sel-controls">
<span class="status-eta">{formatEta(downloadStore.eta)} left</span> <button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
{/if} <ArrowLineUp size={12} weight="bold" />
<span class="status-count">{downloadStore.queue.length} queued</span> </button>
</div> <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>
<div class="content" onclick={handleClickOff}>
<DownloadQueue <DownloadQueue
queue={downloadStore.queue} queue={downloadStore.queue}
loading={downloadStore.loading} loading={downloadStore.loading}
isRunning={downloadStore.isRunning} isRunning={downloadStore.isRunning}
dequeueing={downloadStore.dequeueing} dequeueing={downloadStore.dequeueing}
selected={downloadStore.selected} selected={downloadStore.selected}
batchWorking={downloadStore.batchWorking}
onRemove={(id) => downloadStore.dequeue(id)} onRemove={(id) => downloadStore.dequeue(id)}
onRetry={(id) => downloadStore.retryOne(id)} onRetry={(id) => downloadStore.retryOne(id)}
onReorder={(id, dir) => downloadStore.reorder(id, dir)} onReorder={(id, dir) => downloadStore.reorder(id, dir)}
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)} onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
onSelect={handleSelect} onSelect={handleSelect}
onClearSelect={() => { downloadStore.clearSelection(); selectAnchor = null; }}
onBatchRemove={() => downloadStore.dequeueSelected()}
onBatchRetry={() => downloadStore.retrySelected()}
onBatchReorder={(dir) => downloadStore.reorderSelected(dir)}
onBatchReorderEdge={(edge) => downloadStore.reorderSelectedToEdge(edge)}
/> />
</div> </div>
</div> </div>
<style> <style>
.root { .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.heading {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-normal);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.header-actions { display: flex; gap: var(--sp-2); } .header-actions { display: flex; gap: var(--sp-2); }
.content { .bar-wrap { padding: var(--sp-4) var(--sp-6); flex-shrink: 0; }
flex: 1;
overflow-y: auto;
padding: var(--sp-5) var(--sp-6) var(--sp-6);
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.icon-btn { .status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
display: flex; .status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
align-items: center; .status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
justify-content: center; .status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
width: 28px; .status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
height: 28px; .status-eta { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); opacity: 0.8; }
border-radius: var(--radius-md); .status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
border: 1px solid var(--border-dim);
color: var(--text-muted); .sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
background: none; .status-bar { cursor: default; }
cursor: pointer; .bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
transition: color var(--t-base), border-color var(--t-base), background var(--t-base); .sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
} .sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); white-space: nowrap; }
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } .sel-text-btn:hover { color: var(--text-primary); }
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-action-danger:hover:not(:disabled) { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); }
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); } .icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); } .icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.status-bar { .move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
display: flex; .move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
align-items: center; .move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
gap: var(--sp-3); .move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
padding: var(--sp-3); .move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
background: var(--bg-raised); .move-input:focus { color: var(--text-primary); }
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-faint);
flex-shrink: 0;
transition: background var(--t-base);
}
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
.status-text {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
flex: 1;
letter-spacing: var(--tracking-wide);
}
.status-right {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.status-eta {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--accent-fg);
letter-spacing: var(--tracking-wide);
opacity: 0.8;
}
.status-count {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } } @keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
+39
View File
@@ -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, isRunning, getErrored, calcSpeed, estimateEta,
type SpeedSample, type SpeedSample,
} from "../lib/downloadQueue"; } from "../lib/downloadQueue";
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
class DownloadStore { class DownloadStore {
status: DownloadStatus | null = $state(null); status: DownloadStatus | null = $state(null);
@@ -24,17 +25,39 @@ class DownloadStore {
pagesPerSec: number | null = $state(null); pagesPerSec: number | null = $state(null);
eta: number | null = $state(null); eta: number | null = $state(null);
toastsEnabled = $state(true); toastsEnabled = $state(true);
autoRetryEnabled = $state(false);
private lastSample: SpeedSample | null = null; private lastSample: SpeedSample | null = null;
private prevQueue: DownloadQueueItem[] = []; private prevQueue: DownloadQueueItem[] = [];
private autoRetryHnd: AutoRetryHandle | null = null;
get queue() { return this.status?.queue ?? []; } get queue() { return this.status?.queue ?? []; }
get isRunning() { return isRunning(this.status?.state); } get isRunning() { return isRunning(this.status?.state); }
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); } get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
get hasErrored() { return this.erroredIds.size > 0; } get hasErrored() { return this.erroredIds.size > 0; }
toggleToasts() { this.toastsEnabled = !this.toastsEnabled; } toggleToasts() {
this.toastsEnabled = !this.toastsEnabled;
addToast({ kind: "info", title: this.toastsEnabled ? "Notifications enabled" : "Notifications muted", body: this.toastsEnabled ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
}
toggleAutoRetry() {
if (this.autoRetryEnabled) {
this.autoRetryHnd?.stop();
this.autoRetryHnd = null;
this.autoRetryEnabled = false;
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
} else {
this.autoRetryEnabled = true;
this.autoRetryHnd = startAutoRetry(
() => this.queue,
() => this.isRunning,
() => this.retryAllErrored(),
);
addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 });
}
}
detectTransitions(next: DownloadQueueItem[]) { detectTransitions(next: DownloadQueueItem[]) {
if (!this.toastsEnabled) return; if (!this.toastsEnabled) return;
@@ -101,7 +124,10 @@ class DownloadStore {
this.applyStatus(d.startDownloader.downloadStatus); this.applyStatus(d.startDownloader.downloadStatus);
} }
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }
finally { this.togglingPlay = false; } finally {
this.togglingPlay = false;
addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 });
}
} }
async clear() { async clear() {
@@ -113,6 +139,7 @@ class DownloadStore {
try { try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER); const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
this.applyStatus(d.clearDownloader.downloadStatus); this.applyStatus(d.clearDownloader.downloadStatus);
addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 });
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }
finally { this.clearing = false; } finally { this.clearing = false; }
} }
@@ -137,6 +164,7 @@ class DownloadStore {
try { try {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
this.poll(); this.poll();
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 });
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; } finally { this.batchWorking = false; }
} }
@@ -160,6 +188,7 @@ class DownloadStore {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
this.poll(); this.poll();
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; } finally { this.batchWorking = false; }
} }
@@ -173,6 +202,7 @@ class DownloadStore {
if (ids.length > 0) { if (ids.length > 0) {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
} }
this.poll(); this.poll();
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }
@@ -254,25 +284,29 @@ class DownloadStore {
if (this.batchWorking || this.selected.size === 0) return; if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true; this.batchWorking = true;
const pinned = this.queue.filter((i) => this.selected.has(i.chapter.id)); const first = this.isRunning ? 1 : 0;
const rest = this.queue.filter((i) => !this.selected.has(i.chapter.id)); const active = this.queue.slice(0, first);
const newQueue = edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned]; 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 }; 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 { try {
if (edge === "top") { if (edge === "top") {
for (const item of [...pinned].reverse()) { for (let i = 0; i < pinned.length; i++) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: item.chapter.id, to: first }, REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i },
); );
} }
} else { } else {
for (const item of pinned) { for (let i = 0; i < pinned.length; i++) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( 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"> <script lang="ts">
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch } from "phosphor-svelte"; import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp } from "phosphor-svelte";
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers"; import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
interface Props { interface Props {
@@ -8,6 +8,7 @@
panel: Panel; panel: Panel;
refreshing: boolean; refreshing: boolean;
updateCount: number; updateCount: number;
updatingAll: boolean;
availableLangs: string[]; availableLangs: string[];
langFilter: string | null; langFilter: string | null;
anims: boolean; anims: boolean;
@@ -18,14 +19,15 @@
onLang: (lang: string | null) => void; onLang: (lang: string | null) => void;
onPanel: (p: Panel) => void; onPanel: (p: Panel) => void;
onRefresh: () => void; onRefresh: () => void;
onUpdateAll: () => void;
} }
let { let {
filter, search, panel, refreshing, updateCount, filter, search, panel, refreshing, updateCount, updatingAll,
availableLangs, langFilter, availableLangs, langFilter,
anims, tabIndicator, anims, tabIndicator,
tabsEl = $bindable(), tabsEl = $bindable(),
onFilter, onSearch, onLang, onPanel, onRefresh, onFilter, onSearch, onLang, onPanel, onRefresh, onUpdateAll,
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -57,6 +59,11 @@
<button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh repo"> <button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh repo">
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} /> <ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button> </button>
{#if updateCount > 0}
<button class="icon-btn update-badge" onclick={onUpdateAll} disabled={updatingAll} title="Update all ({updateCount})">
<ArrowCircleUp size={14} weight="fill" class={updatingAll ? "anim-spin" : ""} />
</button>
{/if}
</div> </div>
</div> </div>
@@ -90,6 +97,8 @@
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); } .search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); } .search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); } .search:focus { border-color: var(--border-strong); }
.icon-btn.update-badge { color: var(--accent-fg); }
.icon-btn.update-badge:hover:not(:disabled) { background: var(--accent-muted); }
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); } .lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); } .lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); } .lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
@@ -29,6 +29,7 @@
let search = $state(""); let search = $state("");
let langFilter = $state<string | null>(null); let langFilter = $state<string | null>(null);
let working = $state(new Set<string>()); let working = $state(new Set<string>());
let updatingAll = $state(false);
let expanded = $state(new Set<string>()); let expanded = $state(new Set<string>());
let panel = $state<Panel>(null); let panel = $state<Panel>(null);
@@ -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() { async function installExternal() {
const url = externalUrl.trim(); const url = externalUrl.trim();
const err = validateUrl(url, ".apk"); const err = validateUrl(url, ".apk");
@@ -206,13 +216,14 @@
<div class="root anim-fade-in"> <div class="root anim-fade-in">
<ExtensionFilters <ExtensionFilters
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter} {filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
{anims} {tabIndicator} {anims} {tabIndicator} {updatingAll}
bind:tabsEl bind:tabsEl
onFilter={setFilter} onFilter={setFilter}
onSearch={(q) => search = q} onSearch={(q) => search = q}
onLang={(l) => langFilter = l} onLang={(l) => langFilter = l}
onPanel={openPanel} onPanel={openPanel}
onRefresh={fetchFromRepo} onRefresh={fetchFromRepo}
onUpdateAll={updateAll}
/> />
{#if panel === "apk"} {#if panel === "apk"}
+24 -15
View File
@@ -52,7 +52,6 @@
let selectedIds: Set<number> = $state(new Set()); let selectedIds: Set<number> = $state(new Set());
let selectMode: boolean = $state(false); let selectMode: boolean = $state(false);
let bulkWorking: boolean = $state(false); let bulkWorking: boolean = $state(false);
let bulkMoveOpen: boolean = $state(false);
let bulkAutomateOpen: boolean = $state(false); let bulkAutomateOpen: boolean = $state(false);
let sortPanelOpen: boolean = $state(false); let sortPanelOpen: boolean = $state(false);
@@ -68,7 +67,7 @@
let dragInsertIdx: number = $state(-1); let dragInsertIdx: number = $state(-1);
let dragTabId: number | null = $state(null); let dragTabId: number | null = $state(null);
let dragOverTabId: number | null = $state(null); let dragOverTabId: number | null = $state(null);
let dropTargetTabId: number | null = $state(null);
const DT_TAB = "application/x-moku-tab"; const DT_TAB = "application/x-moku-tab";
const anims = $derived(store.settings.qolAnimations ?? true); const anims = $derived(store.settings.qolAnimations ?? true);
@@ -107,6 +106,14 @@
items = (store.settings.libraryShowAllInSaved ?? true) items = (store.settings.libraryShowAllInSaved ?? true)
? allManga.filter(m => m.inLibrary) ? allManga.filter(m => m.inLibrary)
: (categoryMangaMap.get(0) ?? []); : (categoryMangaMap.get(0) ?? []);
if ((store.settings.libraryShowAllInSaved ?? true) && (store.settings.libraryHideCompletedInSaved ?? false)) {
const completedCat = store.categories.find(c => c.name === COMPLETED_NAME);
if (completedCat) {
const completedIds = new Set((categoryMangaMap.get(completedCat.id) ?? []).map(m => m.id));
items = items.filter(m => !completedIds.has(m.id));
}
}
} else if (tab === "downloaded") { } else if (tab === "downloaded") {
items = allManga.filter(m => (m.downloadCount ?? 0) > 0); items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
} else { } else {
@@ -185,7 +192,7 @@
} }
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); } function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
function exitSelectMode() { selectMode = false; selectedIds = new Set(); bulkMoveOpen = false; } function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); } function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); } function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); } function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
@@ -299,7 +306,7 @@
} }
async function bulkMoveToCategory(cat: Category) { async function bulkMoveToCategory(cat: Category) {
bulkWorking = true; bulkMoveOpen = false; bulkWorking = true;
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? toggleMangaCategory(m, cat) : Promise.resolve(); })); } try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? toggleMangaCategory(m, cat) : Promise.resolve(); })); }
finally { bulkWorking = false; exitSelectMode(); } finally { bulkWorking = false; exitSelectMode(); }
} }
@@ -417,26 +424,30 @@
function onTabDragOver(e: DragEvent, cat: Category, idx: number) { function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return; if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return;
e.preventDefault(); e.dataTransfer!.dropEffect = "move"; e.preventDefault(); e.dataTransfer!.dropEffect = "move";
dragOverTabId = cat.id; dragInsertIdx = idx; dragOverTabId = cat.id;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
} }
function onTabDragLeave() { dragOverTabId = null; } function onTabDragLeave() { dragOverTabId = null; }
async function onTabDrop(e: DragEvent, dropCat: Category) { async function onTabDrop(e: DragEvent, dropCat: Category) {
e.preventDefault(); dragOverTabId = null; dragInsertIdx = -1; e.preventDefault(); dragOverTabId = null;
const insertAt = dragInsertIdx;
dragInsertIdx = -1;
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; } if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
const dragId = dragTabId; dragTabId = null; activeDragKind = null; const dragId = dragTabId; dragTabId = null; activeDragKind = null;
const sorted = [...store.categories].filter(c => c.id !== 0).sort((a, b) => a.order - b.order); const sorted = [...store.categories].filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
const fromIdx = sorted.findIndex(c => c.id === dragId); const fromIdx = sorted.findIndex(c => c.id === dragId);
const toIdx = sorted.findIndex(c => c.id === dropCat.id); if (fromIdx < 0) return;
if (fromIdx < 0 || toIdx < 0) return; const reordered = [...sorted];
const reordered = [...sorted]; const [moved] = reordered.splice(fromIdx, 1);
const [moved] = reordered.splice(fromIdx, 1); const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, reordered.length));
reordered.splice(toIdx, 0, moved); reordered.splice(dest, 0, moved);
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 })); const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 }));
setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c)); setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c));
try { try {
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: toIdx + 1 }); await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: dest + 1 });
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); } } catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
} }
@@ -584,13 +595,11 @@
onRetry={() => retryCount++} onRetry={() => retryCount++}
onExitSelectMode={exitSelectMode} onExitSelectMode={exitSelectMode}
onSelectAll={selectAll} onSelectAll={selectAll}
onBulkMove={(cat) => { bulkMoveOpen = !bulkMoveOpen; }} onBulkMove={bulkMoveToCategory}
onBulkRemove={bulkRemoveFromLibrary} onBulkRemove={bulkRemoveFromLibrary}
onBulkAutomate={bulkAutomate} onBulkAutomate={bulkAutomate}
{bulkWorking} {bulkWorking}
{bulkMoveOpen}
{visibleCategories} {visibleCategories}
onCategoryMove={bulkMoveToCategory}
/> />
{/if} {/if}
</div> </div>
@@ -91,31 +91,35 @@
<span class="tab-count">{counts[f] ?? 0}</span> <span class="tab-count">{counts[f] ?? 0}</span>
</button> </button>
{/each} {/each}
{#each visibleCategories as cat, idx} {#if visibleCategories.length > 0}
{#if dragInsertIdx === idx && activeDragKind === "tab"} <div class="tab-separator" aria-hidden="true"></div>
<div class="tab-insert-bar" aria-hidden="true"></div> <div class="tabs-scroll">
{/if} {#each visibleCategories as cat, idx}
<button {#if dragInsertIdx === idx && activeDragKind === "tab"}
class="tab" <div class="tab-insert-bar" aria-hidden="true"></div>
class:active={tab === String(cat.id)} {/if}
class:tab-dragging={dragTabId === cat.id} <button
class:tab-drop-target={dragOverTabId === cat.id} class="tab"
draggable="true" class:active={tab === String(cat.id)}
onclick={() => onTabChange(String(cat.id))} class:tab-dragging={dragTabId === cat.id}
ondragstart={(e) => onTabDragStart(e, cat)} draggable="true"
ondragover={(e) => onTabDragOver(e, cat, idx)} onclick={() => onTabChange(String(cat.id))}
ondragleave={onTabDragLeave} ondragstart={(e) => onTabDragStart(e, cat)}
ondrop={(e) => onTabDrop(e, cat)} ondragover={(e) => onTabDragOver(e, cat, idx)}
ondragend={onTabDragEnd} ondragleave={onTabDragLeave}
> ondrop={(e) => onTabDrop(e, cat)}
<Folder size={11} weight="bold" /> ondragend={onTabDragEnd}
{cat.name} >
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span> <Folder size={11} weight="bold" />
</button> {cat.name}
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1} <span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
<div class="tab-insert-bar" aria-hidden="true"></div> </button>
{/if} {#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
{/each} <div class="tab-insert-bar" aria-hidden="true"></div>
{/if}
{/each}
</div>
{/if}
</div> </div>
<div class="header-right"> <div class="header-right">
@@ -195,17 +199,19 @@
</div> </div>
<style> <style>
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } .header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; } .header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; } .heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; } .tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; }
.tabs-scroll { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; flex-shrink: 1; }
.tabs-scroll::-webkit-scrollbar { display: none; }
.tab-separator { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 2px; }
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); } .tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: grab; } .tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: grab; flex-shrink: 0; }
.tab:hover { color: var(--text-muted); } .tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid transparent; } .tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid transparent; }
.tabs-anims .tab.active { background: transparent; } .tabs-anims .tab.active { background: transparent; }
.tab-dragging { opacity: 0.4; cursor: grabbing; } .tab-dragging { opacity: 0.4; cursor: grabbing; }
.tab-drop-target { background: var(--accent-muted) !important; color: var(--accent-fg) !important; outline: 1px dashed var(--accent); }
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; } .tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
.tab-count { font-size: var(--text-2xs); opacity: 0.6; } .tab-count { font-size: var(--text-2xs); opacity: 0.6; }
.search-wrap { position: relative; display: flex; align-items: center; } .search-wrap { position: relative; display: flex; align-items: center; }
+88 -5
View File
@@ -3,6 +3,8 @@
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte"; import { readerState } from "../store/readerState.svelte";
import type { StripChapter } from "../lib/scrollHandler"; import type { StripChapter } from "../lib/scrollHandler";
import { createPinchTracker } from "../lib/pinchZoom";
import type { PinchTracker } from "../lib/pinchZoom";
interface Props { interface Props {
style: string; style: string;
@@ -16,6 +18,9 @@
stripToRender: StripChapter[]; stripToRender: StripChapter[];
fadingOut: boolean; fadingOut: boolean;
tapToToggleBar: boolean; tapToToggleBar: boolean;
pinchZoomEnabled: boolean;
onGetZoom: () => number;
onSetZoom: (z: number) => void;
resolveUrl: (url: string, priority?: number) => Promise<string>; resolveUrl: (url: string, priority?: number) => Promise<string>;
onTap: (e: MouseEvent) => void; onTap: (e: MouseEvent) => void;
onWheel: (e: WheelEvent) => void; onWheel: (e: WheelEvent) => void;
@@ -26,7 +31,8 @@
const { const {
style, imgCls, effectiveWidth, loading, error, pageReady, style, imgCls, effectiveWidth, loading, error, pageReady,
pageGroups, currentGroup, stripToRender, fadingOut, pageGroups, currentGroup, stripToRender, fadingOut,
tapToToggleBar, resolveUrl, onTap, onWheel, onToggleUi, bindContainer, tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom,
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
}: Props = $props(); }: Props = $props();
const INSPECT_ZOOM_STEP = 0.15; const INSPECT_ZOOM_STEP = 0.15;
@@ -57,8 +63,38 @@
let inspectPanStartX = 0; let inspectPanStartX = 0;
let inspectPanStartY = 0; let inspectPanStartY = 0;
let stripDragging = false;
let stripDragMoved = false;
let stripDragStartY = 0;
let stripScrollStart = 0;
let pinch: PinchTracker | null = null;
$effect(() => {
if (pinchZoomEnabled) {
pinch = createPinchTracker({
getZoom: onGetZoom,
setZoom: onSetZoom,
getInspectScale: () => readerState.inspectScale,
setInspectScale: (s) => { readerState.inspectScale = s; },
resetInspectPan: () => { readerState.inspectPanX = 0; readerState.inspectPanY = 0; },
isLongstrip: () => style === "longstrip",
});
} else {
pinch = null;
}
});
export function onInspectMouseDown(e: MouseEvent) { export function onInspectMouseDown(e: MouseEvent) {
if (style === "longstrip" || readerState.inspectScale <= 1) return; if (style === "longstrip") {
stripDragging = true;
stripDragMoved = false;
stripDragStartY = e.clientY;
stripScrollStart = containerEl?.scrollTop ?? 0;
e.preventDefault();
return;
}
if (readerState.inspectScale <= 1) return;
inspectDragging = true; inspectDragging = true;
inspectDragMoved = false; inspectDragMoved = false;
inspectDragStartX = e.clientX; inspectDragStartX = e.clientX;
@@ -69,6 +105,12 @@
} }
export function onInspectMouseMove(e: MouseEvent) { export function onInspectMouseMove(e: MouseEvent) {
if (stripDragging) {
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
return;
}
if (!inspectDragging) return; if (!inspectDragging) return;
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true; if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX); const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
@@ -79,12 +121,48 @@
} }
export function onInspectMouseUp() { export function onInspectMouseUp() {
stripDragging = false;
inspectDragging = false; inspectDragging = false;
} }
export function onPointerDown(e: PointerEvent) {
pinch?.onPointerDown(e);
}
export function onPointerMove(e: PointerEvent) {
if (pinch?.isPinching()) {
pinch.onPointerMove(e);
return;
}
if (stripDragging) {
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
}
if (inspectDragging) {
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
const rawY = inspectPanStartY + (e.clientY - inspectDragStartY);
const [cx, cy] = clampInspectPan(readerState.inspectScale, rawX, rawY);
readerState.inspectPanX = cx;
readerState.inspectPanY = cy;
}
}
export function onPointerUp(e: PointerEvent) {
pinch?.onPointerUp(e);
if (!pinch?.isPinching()) {
stripDragging = false;
inspectDragging = false;
}
}
export function handleWheel(e: WheelEvent) { export function handleWheel(e: WheelEvent) {
if (e.ctrlKey) { onWheel(e); return; } if (style === "longstrip") {
if (style === "longstrip") return; if (e.ctrlKey) { onWheel(e); }
return;
}
if (!e.ctrlKey) { onWheel(e); return; }
e.preventDefault(); e.preventDefault();
const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP; const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP;
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, readerState.inspectScale + delta)); const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, readerState.inspectScale + delta));
@@ -107,6 +185,7 @@
function handleTap(e: MouseEvent) { function handleTap(e: MouseEvent) {
if (style === "longstrip") return; if (style === "longstrip") return;
if (inspectDragMoved) { inspectDragMoved = false; return; } if (inspectDragMoved) { inspectDragMoved = false; return; }
if (stripDragMoved) { stripDragMoved = false; return; }
onTap(e); onTap(e);
} }
@@ -127,7 +206,9 @@
onclick={handleTap} onclick={handleTap}
ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }} ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }}
onmousedown={onInspectMouseDown} onmousedown={onInspectMouseDown}
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }} onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }} onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
> >
@@ -189,12 +270,14 @@
</div> </div>
<style> <style>
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; } .viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; touch-action: pan-x pan-y; }
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; } .viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; } .viewer:focus { outline: none; }
.viewer.inspect-active { cursor: grab; overflow: hidden; } .viewer.inspect-active { cursor: grab; overflow: hidden; }
.viewer.inspect-active:active { cursor: grabbing; } .viewer.inspect-active:active { cursor: grabbing; }
:global(.pinch-active) .viewer { touch-action: none; }
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; } .inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.img { display: block; user-select: none; image-rendering: auto; } .img { display: block; user-select: none; image-rendering: auto; }
@@ -46,6 +46,7 @@
const lastPage = $derived(store.pageUrls.length); const lastPage = $derived(store.pageUrls.length);
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined); const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
const zoomPct = $derived(Math.round(zoom * 100)); const zoomPct = $derived(Math.round(zoom * 100));
const pinchZoomEnabled = $derived(store.settings.pinchZoom ?? false);
const displayChapter = $derived( const displayChapter = $derived(
style === "longstrip" && readerState.visibleChapterId style === "longstrip" && readerState.visibleChapterId
@@ -479,6 +480,8 @@
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
window.addEventListener("mousemove", pageViewRef.onInspectMouseMove); window.addEventListener("mousemove", pageViewRef.onInspectMouseMove);
window.addEventListener("mouseup", pageViewRef.onInspectMouseUp); window.addEventListener("mouseup", pageViewRef.onInspectMouseUp);
window.addEventListener("pointermove", pageViewRef.onPointerMove);
window.addEventListener("pointerup", pageViewRef.onPointerUp);
readerState.isFullscreen = await win.isFullscreen(); readerState.isFullscreen = await win.isFullscreen();
const unlistenFs = await win.onResized(async () => { const unlistenFs = await win.onResized(async () => {
@@ -500,6 +503,8 @@
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove); window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove);
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp); window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
cleanupScroll(); cleanupScroll();
unlistenFs(); unlistenFs();
ro.disconnect(); ro.disconnect();
@@ -512,6 +517,7 @@
class:overlay-bars={overlayBars} class:overlay-bars={overlayBars}
class:bar-left={barPosition === "left"} class:bar-left={barPosition === "left"}
class:bar-right={barPosition === "right"} class:bar-right={barPosition === "right"}
class:pinch-active={pinchZoomEnabled}
role="presentation" role="presentation"
onmousemove={(e) => { onmousemove={(e) => {
if (!tapToToggleBar) { if (!tapToToggleBar) {
@@ -580,6 +586,9 @@
{currentGroup} {stripToRender} {currentGroup} {stripToRender}
fadingOut={readerState.fadingOut} fadingOut={readerState.fadingOut}
{tapToToggleBar} {tapToToggleBar}
{pinchZoomEnabled}
onGetZoom={() => zoom}
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)} resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
onTap={handleTap} onTap={handleTap}
onWheel={handleWheel} onWheel={handleWheel}
@@ -625,4 +634,6 @@
.root.bar-left :global(.viewer) { margin-left: 40px; } .root.bar-left :global(.viewer) { margin-left: 40px; }
.root.bar-right :global(.viewer) { margin-right: 40px; } .root.bar-right :global(.viewer) { margin-right: 40px; }
.root.pinch-active :global(.viewer) { touch-action: none; }
</style> </style>
@@ -266,6 +266,16 @@
aria-checked={effectiveSettings.optimizeContrast} aria-checked={effectiveSettings.optimizeContrast}
><span class="toggle-knob"></span></button> ><span class="toggle-knob"></span></button>
</label> </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"> <label class="toggle-row">
<span class="toggle-label">Mark read on chapter advance</span> <span class="toggle-label">Mark read on chapter advance</span>
<button <button
@@ -534,6 +544,19 @@
color: var(--text-secondary); 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 { .toggle {
position: relative; position: relative;
width: 32px; width: 32px;
+4 -1
View File
@@ -3,6 +3,7 @@ import { store, addHistory, addBookmark, removeBookmark,
checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte"; checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters"; import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads"; import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
const AVG_MIN_PER_PAGE = 0.33; const AVG_MIN_PER_PAGE = 0.33;
@@ -30,7 +31,9 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
if (!mangaId) return; if (!mangaId) return;
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c); const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
checkAndMarkCompleted(mangaId, updated); checkAndMarkCompleted(mangaId, updated);
const ch = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
const prefs = getMangaPrefs(); const prefs = getMangaPrefs();
if (ch) trackingState.updateFromRead(mangaId, ch, store.activeChapterList, prefs);
if (prefs.deleteOnRead) { if (prefs.deleteOnRead) {
const ch = store.activeChapterList.find(c => c.id === id); const ch = store.activeChapterList.find(c => c.id === id);
if (ch?.isDownloaded) { if (ch?.isDownloaded) {
@@ -73,4 +76,4 @@ export function toggleBookmark(
if (existing) removeBookmark(existing.chapterId); if (existing) removeBookmark(existing.chapterId);
addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber }); addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber });
} }
} }
+4
View File
@@ -1,6 +1,7 @@
import { store, openReader } from "@store/state.svelte"; import { store, openReader } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte"; import { readerState } from "../store/readerState.svelte";
import { fetchPages } from "./pageLoader"; import { fetchPages } from "./pageLoader";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
export function scheduleResumeDismiss() { export function scheduleResumeDismiss() {
setTimeout(() => { readerState.resumeFading = true; }, 1500); setTimeout(() => { readerState.resumeFading = true; }, 1500);
@@ -23,6 +24,9 @@ export async function loadChapter(
readerState.resetForChapter(); readerState.resetForChapter();
store.pageUrls = []; store.pageUrls = [];
const mangaId = store.activeManga?.id;
if (mangaId) trackingState.loadForManga(mangaId);
const bookmark = store.bookmarks.find(b => b.chapterId === id); const bookmark = store.bookmarks.find(b => b.chapterId === id);
const resumeTo = bookmark ? bookmark.pageNumber : 0; const resumeTo = bookmark ? bookmark.pageNumber : 0;
readerState.resumePage = resumeTo > 1 ? resumeTo : 0; readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
+78
View File
@@ -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, checkAndMarkCompleted as storeCheckAndMarkCompleted,
clearMarkersForManga, clearMarkersForManga,
} from "@store/state.svelte"; } from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import type { MangaPrefs } from "@store/state.svelte"; import type { MangaPrefs } from "@store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte"; import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import type { Manga, Chapter, Category } from "@types"; import type { Manga, Chapter, Category } from "@types";
@@ -80,18 +81,21 @@
const scanlatorBlacklist = $derived((get("scanlatorBlacklist") ?? []) as string[]); const scanlatorBlacklist = $derived((get("scanlatorBlacklist") ?? []) as string[]);
const scanlatorForce = $derived((get("scanlatorForce") ?? false) as boolean); const scanlatorForce = $derived((get("scanlatorForce") ?? false) as boolean);
const currentPrefs = $derived({
sortMode,
sortDir,
preferredScanlator: get("preferredScanlator") as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
});
const availableScanlators = $derived( const availableScanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))] [...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b)) .sort((a, b) => a.localeCompare(b))
); );
const sortedChapters = $derived(buildChapterList(chapters, { const sortedChapters = $derived(buildChapterList(chapters, currentPrefs));
sortMode, sortDir,
preferredScanlator: get("preferredScanlator") as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
}));
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE)); const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE)); const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
@@ -102,24 +106,21 @@
const continueChapter = $derived((() => { const continueChapter = $derived((() => {
if (!sortedChapters.length) return null; if (!sortedChapters.length) return null;
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder); const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some(c => c.isRead); const anyRead = asc.some(c => c.isRead);
const bookmark = store.activeManga const bookmark = store.activeManga
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id) ? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
: null; : null;
if (bookmark) { const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null;
const ch = asc.find(c => c.id === bookmark.chapterId); if (bookmarkedCh && !bookmarkedCh.isRead) {
if (ch) { return { chapter: bookmarkedCh, type: (anyRead ? "continue" : "start") as const, resumePage: bookmark!.pageNumber };
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
const allRead = asc.every(c => c.isRead);
if (!(isLastChapter && allRead))
return { chapter: ch, type: "continue" as const, resumePage: bookmark.pageNumber };
}
} }
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0); const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { chapter: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
const firstUnread = asc.find(c => !c.isRead); const firstUnread = asc.find(c => !c.isRead);
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null }; const target = inProgress ?? firstUnread;
if (target) {
return { chapter: target, type: (anyRead ? "continue" : "start") as const, resumePage: null };
}
return { chapter: asc[0], type: "reread" as const, resumePage: null }; return { chapter: asc[0], type: "reread" as const, resumePage: null };
})()); })());
@@ -160,13 +161,7 @@
function applyChapters(nodes: Chapter[]) { function applyChapters(nodes: Chapter[]) {
if (get("autoDownload") && _prevChapterIds.size > 0) { if (get("autoDownload") && _prevChapterIds.size > 0) {
const filtered = buildChapterList(nodes, { const filtered = buildChapterList(nodes, currentPrefs);
sortMode, sortDir,
preferredScanlator: get("preferredScanlator") as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
});
const newChapters = filtered.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded); const newChapters = filtered.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id)); if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
} }
@@ -256,9 +251,33 @@
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; }); }).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
} }
async function syncTrackersIntoChapters(mangaId: number, chaps: Chapter[]) {
if (!store.settings.trackerSyncBack) return;
const records = trackingState.recordsFor(mangaId);
if (!records.length) return;
for (const record of records) {
try {
const { markedIds } = await trackingState.syncFromRemote(mangaId, record, chaps, currentPrefs);
if (markedIds.length > 0) {
const idSet = new Set(markedIds);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead: true } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
} catch {}
}
}
$effect(() => { $effect(() => {
const m = store.activeManga; const m = store.activeManga;
if (m) untrack(() => { acknowledgeUpdate(m.id); loadManga(m.id); loadChapters(m.id); loadCategories(m.id); }); if (m) untrack(() => {
acknowledgeUpdate(m.id);
loadManga(m.id);
loadChapters(m.id);
loadCategories(m.id);
trackingState.loadForManga(m.id).then(() => {
syncTrackersIntoChapters(m.id, chapters);
});
});
}); });
let prevChapterId: number | null = null; let prevChapterId: number | null = null;
@@ -305,9 +324,21 @@
} }
async function markRead(chapterId: number, isRead: boolean) { async function markRead(chapterId: number, isRead: boolean) {
const mangaId = store.activeManga?.id;
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error); await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c); chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); } if (mangaId) {
chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
checkAndMarkCompleted(mangaId, chapters);
const ch = chapters.find(c => c.id === chapterId);
if (ch) {
if (isRead) {
await trackingState.updateFromRead(mangaId, ch, chapters, currentPrefs);
} else {
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs);
}
}
}
if (isRead) { if (isRead) {
if (get("deleteOnRead")) { if (get("deleteOnRead")) {
const ch = chapters.find(c => c.id === chapterId); const ch = chapters.find(c => c.id === chapterId);
@@ -330,21 +361,34 @@
async function markBulk(ids: number[], isRead: boolean) { async function markBulk(ids: number[], isRead: boolean) {
if (!ids.length) return; if (!ids.length) return;
const mangaId = store.activeManga?.id;
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error); await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
const idSet = new Set(ids); const idSet = new Set(ids);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c); chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); } if (mangaId) {
if (isRead && get("deleteOnRead")) { chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded); checkAndMarkCompleted(mangaId, chapters);
if (toDelete.length) { if (isRead) {
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000; const ascending = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const doDelete = async () => { const lastInBatch = ascending.filter(c => idSet.has(c.id)).at(-1);
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error); if (lastInBatch) await trackingState.updateFromRead(mangaId, lastInBatch, chapters, currentPrefs);
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c); } else {
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); await trackingState.updateFromUnread(mangaId, chapters, currentPrefs);
}; }
if (delayMs === 0) doDelete(); }
else setTimeout(doDelete, delayMs); 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);
}
} }
} }
} }
+62 -37
View File
@@ -1,11 +1,15 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte"; import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise, ArrowLineDown } from "phosphor-svelte";
import { gql } from "@api/client"; import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { GET_TRACKERS, GET_MANGA_TRACK_RECORDS, SEARCH_TRACKER } from "@api/queries/tracking"; import { GET_TRACKERS, SEARCH_TRACKER } from "@api/queries/tracking";
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations/tracking"; import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK } from "@api/mutations/tracking";
import { addToast } from "@store/state.svelte"; import { GET_CHAPTERS } from "@api/queries/chapters";
import { addToast, store } from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
import type { Tracker, TrackRecord, TrackSearch } from "@types"; import type { Tracker, TrackRecord, TrackSearch } from "@types";
import type { Chapter } from "@types/index";
let { mangaId, mangaTitle, onClose }: { let { mangaId, mangaTitle, onClose }: {
mangaId: number; mangaId: number;
@@ -16,8 +20,7 @@
type TabId = "records" | number; type TabId = "records" | number;
let trackers: Tracker[] = $state([]); let trackers: Tracker[] = $state([]);
let records: TrackRecord[] = $state([]); let loadingTrackers: boolean = $state(true);
let loading: boolean = $state(true);
let activeTab: TabId = $state("records"); let activeTab: TabId = $state("records");
let searchQuery: string = $state(""); let searchQuery: string = $state("");
@@ -30,26 +33,22 @@
let syncing: number | null = $state(null); let syncing: number | null = $state(null);
let editingChapter: number | null = $state(null); let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0); let chapterDraft: number = $state(0);
let applyingRecord: number | null = $state(null);
const records = $derived(trackingState.records);
const loading = $derived(trackingState.loading || loadingTrackers);
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); } function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
async function load() { $effect(() => {
loading = true; loadingTrackers = true;
try { gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
const [tRes, rRes] = await Promise.all([ .then(r => { trackers = r.trackers.nodes; })
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS), .catch(e => addToast({ kind: "error", title: "Failed to load trackers", body: e?.message }))
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(GET_MANGA_TRACK_RECORDS, { mangaId }), .finally(() => { loadingTrackers = false; });
]); trackingState.loadForManga(mangaId);
trackers = tRes.trackers.nodes; });
records = rRes.manga.trackRecords.nodes;
} catch (e: any) {
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
} finally {
loading = false;
}
}
$effect(() => { load(); });
$effect(() => { $effect(() => {
const tab = activeTab; const tab = activeTab;
@@ -62,7 +61,6 @@
function trackerFor(id: number) { return trackers.find(t => t.id === id); } function trackerFor(id: number) { return trackers.find(t => t.id === id); }
function recordFor(trackerId: number) { return records.find(r => r.trackerId === trackerId); } function recordFor(trackerId: number) { return records.find(r => r.trackerId === trackerId); }
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
let searchTimer: ReturnType<typeof setTimeout>; let searchTimer: ReturnType<typeof setTimeout>;
@@ -96,7 +94,7 @@
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>( const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId } BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
); );
records = [...records.filter(r => r.trackerId !== activeTab), res.bindTrack.trackRecord]; trackingState.patchRecord(res.bindTrack.trackRecord);
activeTab = "records"; activeTab = "records";
addToast({ kind: "success", title: "Now tracking", body: result.title }); addToast({ kind: "success", title: "Now tracking", body: result.title });
} catch (e: any) { } catch (e: any) {
@@ -110,7 +108,7 @@
updatingRecord = record.id; updatingRecord = record.id;
try { try {
await gql(UNBIND_TRACK, { recordId: record.id }); await gql(UNBIND_TRACK, { recordId: record.id });
records = records.filter(r => r.id !== record.id); trackingState.removeRecord(record.id);
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name }); addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Failed to unlink", body: e?.message }); addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
@@ -119,15 +117,11 @@
} }
} }
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
}
async function updateStatus(record: TrackRecord, status: number) { async function updateStatus(record: TrackRecord, status: number) {
updatingRecord = record.id; updatingRecord = record.id;
try { try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status }); const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
patchRecord(res.updateTrack.trackRecord); trackingState.patchRecord(res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally {
@@ -139,7 +133,7 @@
updatingRecord = record.id; updatingRecord = record.id;
try { try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString }); const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
patchRecord(res.updateTrack.trackRecord); trackingState.patchRecord(res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally {
@@ -151,7 +145,7 @@
updatingRecord = record.id; updatingRecord = record.id;
try { try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private }); const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private });
patchRecord(res.updateTrack.trackRecord); trackingState.patchRecord(res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally {
@@ -162,9 +156,8 @@
async function syncRecord(record: TrackRecord) { async function syncRecord(record: TrackRecord) {
syncing = record.id; syncing = record.id;
try { try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id }); const fresh = await trackingState.syncRecordFromRemote(record.id);
patchRecord(res.fetchTrack.trackRecord); if (fresh) addToast({ kind: "success", title: "Synced from tracker" });
addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message }); addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { } finally {
@@ -179,6 +172,33 @@
function cancelChapterEditor() { editingChapter = null; } function cancelChapterEditor() { editingChapter = null; }
async function applyToLibrary(record: TrackRecord) {
applyingRecord = record.id;
try {
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
const marked = await syncBackFromTracker(
[record],
chapRes.chapters.nodes,
{
threshold: store.settings.trackerSyncBackThreshold ?? null,
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
(query, vars) => gql(query, vars),
);
if (marked.length > 0) {
addToast({ kind: "success", title: `${marked.length} chapter${marked.length !== 1 ? "s" : ""} marked read` });
} else {
addToast({ kind: "info", title: "Already up to date" });
}
} catch (e: any) {
addToast({ kind: "error", title: "Apply failed", body: e?.message });
} finally {
applyingRecord = null;
}
}
async function submitChapter(record: TrackRecord) { async function submitChapter(record: TrackRecord) {
const val = Math.max(0, chapterDraft); const val = Math.max(0, chapterDraft);
editingChapter = null; editingChapter = null;
@@ -186,7 +206,7 @@
updatingRecord = record.id; updatingRecord = record.id;
try { try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }); const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
patchRecord(res.updateTrack.trackRecord); trackingState.patchRecord(res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally {
@@ -269,6 +289,11 @@
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}> <button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} /> <ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button> </button>
{#if store.settings.trackerSyncBack}
<button class="record-icon-btn" title="Apply tracker progress to library" disabled={applyingRecord === record.id} onclick={() => applyToLibrary(record)}>
<ArrowLineDown size={11} weight="light" class={applyingRecord === record.id ? "anim-spin" : ""} />
</button>
{/if}
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}> <button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
<X size={11} weight="bold" /> <X size={11} weight="bold" />
</button> </button>
+37 -2
View File
@@ -226,7 +226,8 @@
flex-shrink: 0; flex-shrink: 0;
transition: background var(--t-base), border-color var(--t-base); transition: background var(--t-base), border-color var(--t-base);
} }
.s-toggle.on { background: var(--accent); border-color: var(--accent); } .s-toggle.on,
.s-toggle-on { background: var(--accent); border-color: var(--accent); }
.s-toggle-thumb { .s-toggle-thumb {
position: absolute; position: absolute;
@@ -238,12 +239,46 @@
background: var(--text-faint); background: var(--text-faint);
transition: transform var(--t-base), background var(--t-base); transition: transform var(--t-base), background var(--t-base);
} }
.s-toggle.on .s-toggle-thumb { .s-toggle.on .s-toggle-thumb,
.s-toggle-on .s-toggle-thumb {
transform: translateX(15px); transform: translateX(15px);
background: var(--bg-void); background: var(--bg-void);
} }
/* ── System theme sync pair ───────────────────────────────────────── */
.s-sync-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
border-top: 1px solid var(--border-dim);
background: var(--border-dim);
}
.s-sync-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: 8px var(--sp-4);
background: var(--bg-raised);
}
.s-sync-label {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
}
.s-sync-item .s-select-btn {
font-size: var(--text-xs);
min-width: 0;
padding: 4px 8px;
}
/* ── Stepper ──────────────────────────────────────────────────────── */ /* ── Stepper ──────────────────────────────────────────────────────── */
.s-stepper { .s-stepper {
display: flex; display: flex;
@@ -162,7 +162,7 @@
{#if tab === "general"} {#if tab === "general"}
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} /> <GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === "appearance"} {:else if tab === "appearance"}
<AppearanceSettings {onOpenThemeEditor} /> <AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {anims} {onOpenThemeEditor} />
{:else if tab === "reader"} {:else if tab === "reader"}
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} /> <ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === "library"} {:else if tab === "library"}
@@ -221,7 +221,7 @@
<p class="s-section-title">Links</p> <p class="s-section-title">Links</p>
<div class="s-section-body"> <div class="s-section-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)"> <div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)">
<a href="https://github.com/Youwes09/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a> <a href="https://github.com/moku-project/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a> <a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a>
</div> </div>
</div> </div>
@@ -1,25 +1,102 @@
<script lang="ts"> <script lang="ts">
import { Pencil, Trash, Plus } from "phosphor-svelte"; import { Pencil, Trash, Plus } from "phosphor-svelte";
import { store, updateSettings, deleteCustomTheme } from "@store/state.svelte"; import { store, updateSettings, deleteCustomTheme } from "@store/state.svelte";
import { mountSystemThemeSync } from "@core/theme";
import { selectPortal } from "@core/actions/selectPortal";
interface Props { interface Props {
selectOpen: string | null;
closingSelect: string | null;
toggleSelect: (id: string) => void;
anims: boolean;
onOpenThemeEditor?: (id?: string | null) => void; onOpenThemeEditor?: (id?: string | null) => void;
} }
let { onOpenThemeEditor }: Props = $props(); let { selectOpen, closingSelect, toggleSelect, anims, onOpenThemeEditor }: Props = $props();
const THEMES: { id: string; label: string; description: string; swatches: string[] }[] = [ const THEMES: { id: string; label: string; description: string; swatches: string[] }[] = [
{ id: "dark", label: "Dark", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] }, { id: "original", label: "Original", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] },
{ id: "high-contrast", label: "High Contrast", description: "Darker base, sharper text", swatches: ["#080808","#111111","#bcd8bc","#ffffff"] }, { id: "dark", label: "Dark", description: "Darker base, sharper text", swatches: ["#080808","#111111","#bcd8bc","#ffffff"] },
{ id: "light", label: "Light", description: "Warm off-white", swatches: ["#f4f2ee","#faf8f4","#2a5a2a","#1a1916"] }, { id: "light", label: "Light", description: "Warm off-white", swatches: ["#f4f2ee","#faf8f4","#2a5a2a","#1a1916"] },
{ id: "light-contrast", label: "Light Contrast", description: "Light with maximum contrast", swatches: ["#ece8e2","#f5f2ec","#183818","#080806"] },
{ id: "midnight", label: "Midnight", description: "Deep blue-black tint", swatches: ["#0c1020","#101428","#a8b4e8","#eeeef8"] }, { id: "midnight", label: "Midnight", description: "Deep blue-black tint", swatches: ["#0c1020","#101428","#a8b4e8","#eeeef8"] },
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] }, { id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
]; ];
const allThemeOptions = $derived([
...THEMES.map(t => ({ id: t.id, label: t.label })),
...(store.settings.customThemes ?? []).map(t => ({ id: t.id, label: t.name })),
]);
function toggleSync() {
updateSettings({ systemThemeSync: !store.settings.systemThemeSync });
mountSystemThemeSync();
}
let triggerDark: HTMLButtonElement;
let triggerLight: HTMLButtonElement;
</script> </script>
<div class="s-panel"> <div class="s-panel">
<div class="s-section">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Match system theme</span>
<span class="s-desc">Automatically switch theme when your OS switches between light and dark</span>
</div>
<button
class="s-toggle"
class:on={store.settings.systemThemeSync}
onclick={toggleSync}
role="switch"
aria-checked={store.settings.systemThemeSync}
><span class="s-toggle-thumb"></span></button>
</div>
{#if store.settings.systemThemeSync}
<div class="s-sync-pair">
<div class="s-sync-item">
<span class="s-sync-label">Dark theme</span>
<div class="s-select">
<button bind:this={triggerDark} class="s-select-btn" onclick={() => toggleSelect("sync-dark")}>
<span>{allThemeOptions.find(o => o.id === (store.settings.systemThemeDark ?? "dark"))?.label ?? "Original"}</span>
<svg class="s-select-caret" class:open={selectOpen === "sync-dark"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "sync-dark" || closingSelect === "sync-dark"}
<div class="s-select-menu" class:anims class:closing={closingSelect === "sync-dark"} {@attach selectPortal(triggerDark)}>
{#each allThemeOptions as opt}
<button class="s-select-option" class:active={opt.id === (store.settings.systemThemeDark ?? "dark")}
onclick={() => { updateSettings({ systemThemeDark: opt.id }); mountSystemThemeSync(); toggleSelect("sync-dark"); }}>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-sync-item">
<span class="s-sync-label">Light theme</span>
<div class="s-select">
<button bind:this={triggerLight} class="s-select-btn" onclick={() => toggleSelect("sync-light")}>
<span>{allThemeOptions.find(o => o.id === (store.settings.systemThemeLight ?? "light"))?.label ?? "Light"}</span>
<svg class="s-select-caret" class:open={selectOpen === "sync-light"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "sync-light" || closingSelect === "sync-light"}
<div class="s-select-menu" class:anims class:closing={closingSelect === "sync-light"} {@attach selectPortal(triggerLight)}>
{#each allThemeOptions as opt}
<button class="s-select-option" class:active={opt.id === (store.settings.systemThemeLight ?? "light")}
onclick={() => { updateSettings({ systemThemeLight: opt.id }); mountSystemThemeSync(); toggleSelect("sync-light"); }}>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
<div class="s-section"> <div class="s-section">
<p class="s-section-title">Theme</p> <p class="s-section-title">Theme</p>
<div class="s-theme-grid"> <div class="s-theme-grid">
@@ -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> <div class="s-row-info"><span class="s-label">Show all in Saved tab</span><span class="s-desc">Include manga that are in folders — lets you see your whole library in one place</span></div>
<button role="switch" aria-checked={store.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={store.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(store.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={store.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(store.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button>
</label> </label>
{#if store.settings.libraryShowAllInSaved ?? true}
<label class="s-row">
<div class="s-row-info"><span class="s-label">Hide completed in Saved tab</span><span class="s-desc">Keep manga in the Completed folder out of the Saved view</span></div>
<button role="switch" aria-checked={store.settings.libraryHideCompletedInSaved ?? false} aria-label="Hide completed manga in Saved tab" class="s-toggle" class:on={store.settings.libraryHideCompletedInSaved ?? false} onclick={() => updateSettings({ libraryHideCompletedInSaved: !(store.settings.libraryHideCompletedInSaved ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
{/if}
</div> </div>
</div> </div>
@@ -3,7 +3,12 @@
import { GET_TRACKERS } from "@api/queries/tracking"; import { GET_TRACKERS } from "@api/queries/tracking";
import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking"; import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking";
import { open as openUrl } from "@tauri-apps/plugin-shell"; import { open as openUrl } from "@tauri-apps/plugin-shell";
import type { Tracker } from "../../lib/types"; import { store, updateSettings, addToast } from "@store/state.svelte";
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
import { GET_ALL_TRACKER_RECORDS } from "@api/queries/tracking";
import { GET_CHAPTERS } from "@api/queries/chapters";
import type { Tracker, TrackRecord } from "../../lib/types";
import type { Chapter } from "@types/index";
let trackers = $state<Tracker[]>([]); let trackers = $state<Tracker[]>([]);
let trackersLoading = $state(false); let trackersLoading = $state(false);
@@ -78,6 +83,42 @@
} }
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); } function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
let syncing = $state(false);
async function runSyncAll() {
syncing = true;
try {
const res = await gql<{ trackers: { nodes: any[] } }>(GET_ALL_TRACKER_RECORDS);
const allTrackers = res.trackers.nodes.filter((t: any) => t.isLoggedIn);
let totalMarked = 0;
for (const tracker of allTrackers) {
for (const record of tracker.trackRecords.nodes as TrackRecord[]) {
if (!record.manga?.id) continue;
const mangaId = record.manga.id;
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
const marked = await syncBackFromTracker(
[record],
chapRes.chapters.nodes,
{
threshold: store.settings.trackerSyncBackThreshold ?? null,
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
(query, vars) => gql(query, vars),
);
totalMarked += marked.length;
}
}
addToast({ kind: "success", title: "Sync complete", body: `${totalMarked} chapter${totalMarked !== 1 ? "s" : ""} marked read` });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { syncing = false; }
}
</script> </script>
<div class="s-panel"> <div class="s-panel">
@@ -148,4 +189,64 @@
</div> </div>
</div> </div>
<div class="s-section">
<p class="s-section-title">Sync back from tracker</p>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Enable sync back</span>
<span class="s-desc">Mark chapters read locally based on tracker progress</span>
</div>
<button class="s-toggle" class:on={store.settings.trackerSyncBack}
onclick={() => updateSettings({ trackerSyncBack: !store.settings.trackerSyncBack })}
role="switch" aria-checked={store.settings.trackerSyncBack}>
<span class="s-toggle-thumb"></span>
</button>
</div>
{#if store.settings.trackerSyncBack}
<label class="s-row">
<div class="s-row-info">
<span class="s-label">Chapter number tolerance</span>
<span class="s-desc">Allow source and tracker chapter numbers to differ by up to the set amount. When off, the tracker number is used as-is with no range check.</span>
</div>
<button role="switch" aria-checked={store.settings.trackerSyncBackThreshold !== null} class="s-toggle" class:on={store.settings.trackerSyncBackThreshold !== null}
onclick={() => updateSettings({ trackerSyncBackThreshold: store.settings.trackerSyncBackThreshold !== null ? null : 20 })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
{#if store.settings.trackerSyncBackThreshold !== null}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Tolerance</span><span class="s-desc">Max chapter number difference allowed (120)</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.max(1, (store.settings.trackerSyncBackThreshold ?? 20) - 1) })}></button>
<span class="s-step-val">{store.settings.trackerSyncBackThreshold}</span>
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.min(20, (store.settings.trackerSyncBackThreshold ?? 20) + 1) })}>+</button>
</div>
</div>
{/if}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Respect scanlator filter</span>
<span class="s-desc">Only mark chapters matching the series' active scanlator filter</span>
</div>
<button class="s-toggle" class:on={store.settings.trackerRespectScanlatorFilter}
onclick={() => updateSettings({ trackerRespectScanlatorFilter: !store.settings.trackerRespectScanlatorFilter })}
role="switch" aria-checked={store.settings.trackerRespectScanlatorFilter}>
<span class="s-toggle-thumb"></span>
</button>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Sync now</span>
<span class="s-desc">Apply tracker progress to all linked manga in your library</span>
</div>
<button class="s-btn" onclick={runSyncAll} disabled={syncing}>
{syncing ? "Syncing…" : "Sync all"}
</button>
</div>
{/if}
</div>
</div> </div>
+49 -588
View File
@@ -1,193 +1,67 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass } from "phosphor-svelte"; import { CircleNotch } from "phosphor-svelte";
import { gql } from "@api/client"; import { trackingState } from "@features/tracking/store/trackingState.svelte";
import { addToast, setActiveManga, setNavPage } from "@store/state.svelte";
import { GET_ALL_TRACKER_RECORDS } from "@api/queries";
import { UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { TrackRecord } from "@types/index";
import { import {
flattenRecords, filterRecords, sortRecords, dedupeStatuses, flattenRecords, filterRecords, sortRecords, dedupeStatuses,
scoreToStars, calcProgress, patchTracker, removeRecord, type FlatRecord, type SortKey,
type TrackerWithRecords, type FlatRecord, type SortKey,
} from "../lib/trackingSync"; } from "../lib/trackingSync";
import TrackingToolbar from "./TrackingToolbar.svelte";
let trackers = $state<TrackerWithRecords[]>([]); import TrackingCard from "./TrackingCard.svelte";
let loading = $state(true); import TrackingPreview from "./TrackingPreview.svelte";
let error = $state<string | null>(null);
let activeTrackerId = $state<number | "all">("all"); let activeTrackerId = $state<number | "all">("all");
let statusFilter = $state<number | "all">("all"); let statusFilter = $state<number | "all">("all");
let searchQuery = $state(""); let searchQuery = $state("");
let sortBy = $state<SortKey>("title"); let sortBy = $state<SortKey>("title");
let selectedRecord = $state<FlatRecord | null>(null);
let updatingId = $state<number | null>(null); $effect(() => {
let syncingId = $state<number | null>(null); if (trackingState.allTrackers.length === 0 && !trackingState.loadingAll) {
let editingChapter = $state<number | null>(null); trackingState.loadAll();
let chapterDraft = $state(0); }
let confirmUnbind = $state<FlatRecord | null>(null); });
async function load() {
loading = true; error = null;
try {
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
trackers = res.trackers.nodes;
} catch (e: any) {
error = e?.message ?? "Failed to load tracking data";
} finally { loading = false; }
}
$effect(() => { load(); });
const loggedIn = $derived(trackers.filter((t) => t.isLoggedIn));
const allRecords = $derived(flattenRecords(trackers));
const totalCount = $derived(allRecords.length);
const loggedIn = $derived(trackingState.allTrackers.filter(t => t.isLoggedIn));
const allRecords = $derived(flattenRecords(trackingState.allTrackers));
const totalCount = $derived(allRecords.length);
const statusOptions = $derived( const statusOptions = $derived(
activeTrackerId === "all" activeTrackerId === "all"
? dedupeStatuses(trackers) ? dedupeStatuses(trackingState.allTrackers)
: loggedIn.find((t) => t.id === activeTrackerId)?.statuses ?? [] : loggedIn.find(t => t.id === activeTrackerId)?.statuses ?? []
); );
const filtered = $derived( const filtered = $derived(
sortRecords(filterRecords(allRecords, activeTrackerId, statusFilter, searchQuery), sortBy) sortRecords(filterRecords(allRecords, activeTrackerId, statusFilter, searchQuery), sortBy)
); );
async function updateStatus(record: FlatRecord, status: number) {
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function updateScore(record: FlatRecord, scoreString: string) {
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function syncRecord(record: FlatRecord) {
syncingId = record.id;
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
trackers = patchTracker(trackers, record.trackerId, res.fetchTrack.trackRecord);
addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { syncingId = null; }
}
async function unbind(record: FlatRecord) {
updatingId = record.id;
try {
await gql(UNBIND_TRACK, { recordId: record.id });
trackers = removeRecord(trackers, record.trackerId, record.id);
addToast({ kind: "info", title: `Unlinked from ${record.tracker.name}` });
} catch (e: any) {
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
} finally { updatingId = null; }
}
async function submitChapter(record: FlatRecord) {
const val = Math.max(0, chapterDraft);
editingChapter = null;
if (val === record.lastChapterRead) return;
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
function openManga(record: FlatRecord) {
if (!record.manga) return;
setActiveManga(record.manga as any);
setNavPage("library");
}
function openChapterEditor(record: FlatRecord) {
editingChapter = record.id;
chapterDraft = record.lastChapterRead;
}
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
</script> </script>
<div class="page"> <div class="page">
<TrackingToolbar
<div class="header"> {loggedIn}
<div class="header-top"> {totalCount}
<h1 class="heading">Tracking</h1> {activeTrackerId}
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh"> {statusFilter}
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} /> {statusOptions}
</button> {searchQuery}
</div> {sortBy}
loading={trackingState.loadingAll}
{#if !loading && loggedIn.length > 0} onRefresh={() => trackingState.loadAll()}
<div class="tracker-tabs"> onTrackerChange={(id) => { activeTrackerId = id; statusFilter = "all"; }}
<button onStatusChange={(v) => statusFilter = v}
class="tracker-tab" class:active={activeTrackerId === "all"} onSearchChange={(v) => searchQuery = v}
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }} onSortChange={(v) => sortBy = v}
> />
All
<span class="tab-pill">{totalCount}</span>
</button>
{#each loggedIn as t}
<button
class="tracker-tab" class:active={activeTrackerId === t.id}
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
>
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
{t.name}
<span class="tab-pill">{t.trackRecords.nodes.length}</span>
</button>
{/each}
</div>
<div class="filter-bar">
<div class="search-wrap">
<MagnifyingGlass size={12} weight="light" class="search-ico" />
<input class="filter-input" placeholder="Search…" bind:value={searchQuery} />
</div>
<select class="filter-select" bind:value={statusFilter}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
statusFilter = v === "all" ? "all" : parseInt(v);
}}>
<option value="all">All statuses</option>
{#each statusOptions as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select class="filter-select" bind:value={sortBy}>
<option value="title">Title</option>
<option value="status">Status</option>
<option value="score">Score</option>
<option value="progress">Progress</option>
</select>
</div>
{/if}
</div>
<div class="body"> <div class="body">
{#if loading} {#if trackingState.loadingAll}
<div class="state"> <div class="state">
<CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div> </div>
{:else if error} {:else if trackingState.error}
<div class="state"> <div class="state">
<span class="state-error">{error}</span> <span class="state-error">{trackingState.error}</span>
<button class="ghost-btn" onclick={load}>Retry</button> <button class="ghost-btn" onclick={() => trackingState.loadAll()}>Retry</button>
</div> </div>
{:else if loggedIn.length === 0} {:else if loggedIn.length === 0}
@@ -207,240 +81,28 @@
{:else} {:else}
<div class="grid"> <div class="grid">
{#each filtered as record (record.tracker.id + ":" + record.id)} {#each filtered as record (record.tracker.id + ":" + record.id)}
{@const isBusy = updatingId === record.id} <TrackingCard
{@const isSyncing = syncingId === record.id} {record}
{@const progress = calcProgress(record.lastChapterRead, record.totalChapters)} active={selectedRecord?.id === record.id && selectedRecord?.tracker.id === record.tracker.id}
{@const stars = scoreToStars(record.displayScore, record.tracker.scores)} onSelect={(r) => selectedRecord = r}
/>
<div class="card" class:busy={isBusy}>
<div class="cover-wrap">
<div class="cover-click"
role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
{#if record.manga?.thumbnailUrl}
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="cover-img" />
{:else}
<div class="cover-empty"></div>
{/if}
</div>
<div class="cover-actions">
{#if record.private}
<span class="cover-btn" title="Private"><Lock size={10} weight="fill" /></span>
{/if}
{#if isSyncing}
<span class="cover-btn"><CircleNotch size={10} weight="light" class="anim-spin" /></span>
{:else}
<button class="cover-btn" title="Sync" onclick={() => syncRecord(record)}>
<ArrowsClockwise size={10} weight="light" />
</button>
{/if}
{#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="cover-btn" title="Open on {record.tracker.name}">
<ArrowSquareOut size={10} weight="light" />
</a>
{/if}
<button class="cover-btn destroy" title="Unlink" onclick={() => confirmUnbind = record} disabled={isBusy}>
<X size={10} weight="bold" />
</button>
</div>
<div class="tracker-badge">
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="badge-img" />
</div>
</div>
<div class="card-body">
<div class="stars">
{#each Array(5) as _, i}
<span class="star" class:lit={i < stars}>★</span>
{/each}
</div>
<div class="title-block"
role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
<span class="title">{record.title}</span>
{#if record.manga?.title && record.manga.title !== record.title}
<span class="local-title">{record.manga.title}</span>
{/if}
</div>
<div class="controls-row">
<select class="status-select"
value={record.status} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}>
{#each (record.tracker.statuses ?? []) as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select class="score-select"
value={record.displayScore} disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}>
{#each (record.tracker.scores ?? []) as s}
<option value={s}> {s}</option>
{/each}
</select>
</div>
{#if editingChapter === record.id}
<div class="chapter-editor" role="presentation" onclick={(e) => e.stopPropagation()}>
<div class="chapter-editor-top">
<span class="chapter-label">Chapter</span>
<div class="chapter-input-row">
<input
type="number" class="chapter-input"
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
step="0.5" bind:value={chapterDraft}
onkeydown={(e) => {
if (e.key === "Enter") submitChapter(record);
if (e.key === "Escape") editingChapter = null;
}}
use:focusEl
/>
{#if record.totalChapters > 0}
<span class="chapter-total">/ {record.totalChapters}</span>
{/if}
</div>
</div>
{#if record.totalChapters > 0}
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
{/if}
<div class="chapter-actions">
<button class="chapter-cancel" onclick={() => editingChapter = null}>Cancel</button>
<button class="chapter-save" onclick={() => submitChapter(record)}>Save</button>
</div>
</div>
{:else}
<div class="progress-block"
role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
>
<div class="progress-labels">
<span class="progress-text">
{#if progress !== null}
Ch.&nbsp;{record.lastChapterRead}&thinsp;/&thinsp;{record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch.&nbsp;{record.lastChapterRead}&nbsp;read
{:else}
Set chapter…
{/if}
</span>
{#if progress !== null}
<span class="progress-pct">{Math.round(progress)}%</span>
{/if}
</div>
<div class="progress-track">
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
</div>
</div>
{/if}
</div>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
{#if confirmUnbind} {#if selectedRecord}
{@const r = confirmUnbind} <TrackingPreview record={selectedRecord} onClose={() => selectedRecord = null} />
<div class="modal-backdrop" role="presentation" onclick={() => confirmUnbind = null}>
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="modal-icon"><X size={16} weight="bold" /></div>
<p class="modal-title">Unlink from {r.tracker.name}?</p>
<p class="modal-body">
<strong>{r.title}</strong> will be removed from your list. Your progress on {r.tracker.name} is unaffected.
</p>
<div class="modal-actions">
<button class="modal-cancel" onclick={() => confirmUnbind = null}>Cancel</button>
<button class="modal-confirm" onclick={async () => { const rec = r; confirmUnbind = null; await unbind(rec); }}>Unlink</button>
</div>
</div>
</div>
{/if} {/if}
<style> <style>
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.16s ease both; } .page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); } .body {
.header-top { flex: 1; overflow-y: auto; padding: var(--sp-5);
display: flex; align-items: center; justify-content: space-between; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
padding: var(--sp-4) var(--sp-6) var(--sp-3);
} }
.heading {
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none;
transition: color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.tracker-tabs {
display: flex; align-items: center; gap: 1px;
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
}
.tracker-tabs::-webkit-scrollbar { display: none; }
.tracker-tab {
display: flex; align-items: center; gap: var(--sp-2);
padding: 9px 10px 8px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); background: none; border: none;
border-bottom: 2px solid transparent;
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
transition: color var(--t-base), border-color var(--t-base);
}
.tracker-tab:hover { color: var(--text-muted); }
.tracker-tab.active { color: var(--text-secondary); border-bottom-color: var(--accent); }
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
.tab-pill {
font-size: 10px; padding: 0 5px; border-radius: var(--radius-full);
background: var(--bg-overlay); color: var(--text-faint);
min-width: 18px; text-align: center; line-height: 17px;
}
.tracker-tab.active .tab-pill { background: var(--accent-muted); color: var(--accent-fg); }
.filter-bar {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-5);
border-top: 1px solid var(--border-dim);
}
.search-wrap {
display: flex; align-items: center; gap: var(--sp-2); flex: 1;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 4px 10px;
}
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
.filter-input {
flex: 1; background: none; border: none; outline: none;
font-size: var(--text-sm); color: var(--text-primary); min-width: 0;
}
.filter-input::placeholder { color: var(--text-faint); }
.filter-select {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px 22px 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised);
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base);
flex-shrink: 0;
}
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
.body { flex: 1; overflow-y: auto; padding: var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
.state { .state {
display: flex; flex-direction: column; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
@@ -449,6 +111,7 @@
.state-text { font-size: var(--text-sm); color: var(--text-muted); } .state-text { font-size: var(--text-sm); color: var(--text-muted); }
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); max-width: 260px; line-height: 1.5; } .state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); max-width: 260px; line-height: 1.5; }
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); } .state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
.ghost-btn { .ghost-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 14px; border-radius: var(--radius-md); padding: 5px 14px; border-radius: var(--radius-md);
@@ -462,206 +125,4 @@
grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(178px, 1fr));
gap: var(--sp-4); align-content: start; gap: var(--sp-4); align-content: start;
} }
</style>
.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>
@@ -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>
+47
View File
@@ -1,4 +1,7 @@
import type { Tracker, TrackRecord } from "@types/index"; import type { Tracker, TrackRecord } from "@types/index";
import type { Chapter } from "@types/index";
import { MARK_CHAPTERS_READ } from "@api/mutations/chapters";
import { buildChapterList, type ChapterDisplayPrefs } from "@features/series/lib/chapterList";
export interface TrackerWithRecords extends Tracker { export interface TrackerWithRecords extends Tracker {
trackRecords: { nodes: TrackRecord[] }; trackRecords: { nodes: TrackRecord[] };
@@ -109,3 +112,47 @@ export function removeRecord(
} }
); );
} }
export interface SyncBackOptions {
respectScanlatorFilter: boolean;
chapterPrefs: ChapterDisplayPrefs;
}
export async function syncBackFromTracker(
records: TrackRecord[],
chapters: Chapter[],
opts: SyncBackOptions,
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
): Promise<{ markedRead: number[]; markedUnread: number[] }> {
const eligible = buildChapterList(
opts.respectScanlatorFilter ? buildChapterList(chapters, opts.chapterPrefs) : chapters,
{ ...opts.chapterPrefs, sortDir: "asc" },
);
const toMarkRead: number[] = [];
const toMarkUnread: number[] = [];
for (const record of records) {
const remote = record.lastChapterRead;
if (!remote || remote <= 0) continue;
const position = Math.round(remote);
const below = eligible.slice(0, position);
const above = eligible.slice(position);
toMarkRead.push(...below.filter(c => !c.isRead).map(c => c.id));
toMarkUnread.push(...above.filter(c => c.isRead).map(c => c.id));
}
const readIds = [...new Set(toMarkRead)];
const unreadIds = [...new Set(toMarkUnread)];
if (readIds.length > 0) {
await gqlFn(MARK_CHAPTERS_READ, { ids: readIds, isRead: true });
}
if (unreadIds.length > 0) {
await gqlFn(MARK_CHAPTERS_READ, { ids: unreadIds, isRead: false });
}
return { markedRead: readIds, markedUnread: unreadIds };
}
@@ -0,0 +1,303 @@
import { gql } from "@api/client";
import { GET_MANGA_TRACK_RECORDS, GET_ALL_TRACKER_RECORDS } from "@api/queries/tracking";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { UPDATE_TRACK, FETCH_TRACK, UNBIND_TRACK } from "@api/mutations/tracking";
import { MARK_CHAPTERS_READ } from "@api/mutations/chapters";
import { buildChapterList, type ChapterDisplayPrefs } from "@features/series/lib/chapterList";
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
import { store } from "@store/state.svelte";
import type { TrackRecord, Tracker } from "@types/index";
import type { Chapter } from "@types/index";
import type { TrackerWithRecords } from "@features/tracking/lib/trackingSync";
const BOOT_SYNC_RATE_MS = 400;
type RecordMap = Map<number, TrackRecord[]>;
type MangaBucket = { mangaId: number; records: TrackRecord[] };
class TrackingState {
private byManga: RecordMap = $state(new Map());
allTrackers: TrackerWithRecords[] = $state([]);
loadingAll: boolean = $state(false);
loadingFor: Set<number> = $state(new Set());
error: string | null = $state(null);
recordsFor(mangaId: number): TrackRecord[] {
return this.byManga.get(mangaId) ?? [];
}
private setFor(mangaId: number, records: TrackRecord[]) {
const next = new Map(this.byManga);
next.set(mangaId, records);
this.byManga = next;
}
private patchFor(mangaId: number, updated: Partial<TrackRecord> & { id: number }) {
const records = this.recordsFor(mangaId).map(r =>
r.id === updated.id ? { ...r, ...updated } : r
);
this.setFor(mangaId, records);
this.allTrackers = this.allTrackers.map(t => ({
...t,
trackRecords: {
nodes: t.trackRecords.nodes.map(r =>
r.id === updated.id ? { ...r, ...updated } : r
),
},
}));
}
async loadForManga(mangaId: number) {
if (this.loadingFor.has(mangaId)) return;
const existing = this.byManga.get(mangaId);
if (existing && existing.length > 0) return;
const next = new Set(this.loadingFor);
next.add(mangaId);
this.loadingFor = next;
try {
const res = await gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(
GET_MANGA_TRACK_RECORDS, { mangaId }
);
this.setFor(mangaId, res.manga.trackRecords.nodes);
} catch (e: any) {
this.error = e?.message ?? "Failed to load tracking";
} finally {
const s = new Set(this.loadingFor);
s.delete(mangaId);
this.loadingFor = s;
}
}
async loadAll() {
this.loadingAll = true;
this.error = null;
try {
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
this.allTrackers = res.trackers.nodes;
for (const tracker of res.trackers.nodes.filter(t => t.isLoggedIn)) {
for (const record of tracker.trackRecords.nodes) {
if (!record.manga?.id) continue;
const mangaId = record.manga.id;
const existing = this.byManga.get(mangaId) ?? [];
const merged = [...existing.filter(r => r.id !== record.id), record];
this.setFor(mangaId, merged);
}
}
} catch (e: any) {
this.error = e?.message ?? "Failed to load tracking";
} finally {
this.loadingAll = false;
}
}
async updateStatus(mangaId: number, record: TrackRecord, status: number): Promise<TrackRecord> {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, status }
);
this.patchFor(mangaId, res.updateTrack.trackRecord);
return res.updateTrack.trackRecord;
}
async updateScore(mangaId: number, record: TrackRecord, scoreString: string): Promise<TrackRecord> {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, scoreString }
);
this.patchFor(mangaId, res.updateTrack.trackRecord);
return res.updateTrack.trackRecord;
}
async updateChapterProgress(mangaId: number, record: TrackRecord, lastChapterRead: number): Promise<TrackRecord> {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, lastChapterRead }
);
this.patchFor(mangaId, res.updateTrack.trackRecord);
return res.updateTrack.trackRecord;
}
async unbind(mangaId: number, record: TrackRecord) {
await gql(UNBIND_TRACK, { recordId: record.id });
this.setFor(mangaId, this.recordsFor(mangaId).filter(r => r.id !== record.id));
this.allTrackers = this.allTrackers.map(t => ({
...t,
trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== record.id) },
}));
}
async syncFromRemote(
mangaId: number,
record: TrackRecord,
chapters: Chapter[],
prefs: ChapterDisplayPrefs,
): Promise<{ fresh: TrackRecord; markedIds: number[] }> {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
FETCH_TRACK, { recordId: record.id }
);
const fresh = res.fetchTrack.trackRecord;
this.patchFor(mangaId, fresh);
const { markedRead } = await this._applyRemoteProgress(fresh, chapters, prefs);
return { fresh, markedIds: markedRead };
}
private async _applyRemoteProgress(
record: TrackRecord,
chapters: Chapter[],
prefs: ChapterDisplayPrefs,
): Promise<{ markedRead: number[]; markedUnread: number[] }> {
if (!store.settings.trackerSyncBack) return { markedRead: [], markedUnread: [] };
return syncBackFromTracker(
[record],
chapters,
{
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
(query, vars) => gql(query, vars),
);
}
async updateFromRead(
mangaId: number,
chapter: Chapter,
chapterList: Chapter[],
prefs: ChapterDisplayPrefs,
) {
const filtered = buildChapterList(chapterList, { ...prefs, sortDir: "asc" });
const idx = filtered.findIndex(c => c.id === chapter.id);
if (idx < 0) return;
const position = idx + 1;
const records = this.recordsFor(mangaId);
for (const record of records) {
try {
const completedValue = this._completedStatusFor(record.trackerId);
const isCompleted = completedValue !== null && record.status === completedValue;
const readingValue = this._readingStatusFor(record.trackerId);
const belowMax = record.totalChapters > 0 && (record.lastChapterRead ?? 0) < record.totalChapters;
if ((isCompleted || belowMax) && readingValue !== null && position > (record.lastChapterRead ?? 0)) {
await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, lastChapterRead: position, status: readingValue }
).then(res => this.patchFor(mangaId, res.updateTrack.trackRecord));
} else if (!isCompleted && position > (record.lastChapterRead ?? 0)) {
await this.updateChapterProgress(mangaId, record, position);
}
} catch {}
}
}
async updateFromUnread(
mangaId: number,
chapterList: Chapter[],
prefs: ChapterDisplayPrefs,
) {
const filtered = buildChapterList(chapterList, { ...prefs, sortDir: "asc" });
const lastRead = [...filtered].reverse().find(c => c.isRead);
const position = lastRead ? filtered.findIndex(c => c.id === lastRead.id) + 1 : 0;
const records = this.recordsFor(mangaId);
for (const record of records.filter(r => (r.lastChapterRead ?? 0) > position)) {
try {
const completedValue = this._completedStatusFor(record.trackerId);
const isCompleted = completedValue !== null && record.status === completedValue;
const belowMax = record.totalChapters > 0 && position < record.totalChapters;
const readingValue = this._readingStatusFor(record.trackerId);
if ((isCompleted || belowMax) && readingValue !== null) {
await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, lastChapterRead: position, status: readingValue }
).then(res => this.patchFor(mangaId, res.updateTrack.trackRecord));
} else {
await this.updateChapterProgress(mangaId, record, position);
}
} catch {}
}
}
clear(mangaId: number) {
const next = new Map(this.byManga);
next.delete(mangaId);
this.byManga = next;
}
private _statusesFor(trackerId: number): { value: number; name: string }[] {
return this.allTrackers.find(t => t.id === trackerId)?.statuses ?? [];
}
private _completedStatusFor(trackerId: number): number | null {
const s = this._statusesFor(trackerId).find(s => s.name.toLowerCase() === "completed");
return s?.value ?? null;
}
private _readingStatusFor(trackerId: number): number | null {
const s = this._statusesFor(trackerId).find(s => s.name.toLowerCase() === "reading");
return s?.value ?? null;
}
async bootSync() {
if (!store.settings.trackerSyncBack) return;
if (this.allTrackers.length === 0) await this.loadAll();
const buckets = new Map<number, MangaBucket>();
for (const tracker of this.allTrackers.filter(t => t.isLoggedIn)) {
const completedValue = this._completedStatusFor(tracker.id);
for (const record of tracker.trackRecords.nodes) {
const mangaId = record.manga?.id;
if (!mangaId) continue;
if (completedValue !== null && record.status === completedValue) continue;
const bucket = buckets.get(mangaId) ?? { mangaId, records: [] };
bucket.records.push(record);
buckets.set(mangaId, bucket);
}
}
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
for (const { mangaId, records } of buckets.values()) {
const prefs = { ...(store.settings.mangaPrefs?.[mangaId] ?? {}) } as ChapterDisplayPrefs;
let chapters: Chapter[];
try {
const res = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
chapters = res.chapters.nodes;
} catch {
continue;
}
const freshRecords: TrackRecord[] = [];
for (const record of records) {
await delay(BOOT_SYNC_RATE_MS);
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
const fresh = res.fetchTrack.trackRecord;
this.patchFor(mangaId, fresh);
freshRecords.push(fresh);
} catch {
freshRecords.push(record);
}
}
try {
await syncBackFromTracker(
freshRecords,
chapters,
{
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
(query, vars) => gql(query, vars),
);
} catch {}
}
}
}
export const trackingState = new TrackingState();
+1 -1
View File
@@ -43,6 +43,6 @@
<style> <style>
.frame { display: flex; padding: 6px 15px 15px; width: 100%; height: 100%; box-sizing: border-box; overflow: hidden; } .frame { display: flex; padding: 6px 15px 15px; width: 100%; height: 100%; box-sizing: border-box; overflow: hidden; }
.shell { display: flex; flex: 1; border-radius: 14px; overflow: hidden; border: 1px solid var(--border-dim); background: var(--bg-base); min-height: 0; min-width: 0; } .shell { display: flex; flex: 1; border-radius: 14px; overflow: hidden; border: 1px solid var(--border-dim); background: var(--bg-base); background-image: var(--bg-image); min-height: 0; min-width: 0; }
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; min-width: 0; } .main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; min-width: 0; }
</style> </style>
+4
View File
@@ -1,5 +1,6 @@
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { probeServer, loginBasic } from "@core/auth"; import { probeServer, loginBasic } from "@core/auth";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
const MAX_ATTEMPTS = 40; const MAX_ATTEMPTS = 40;
@@ -33,6 +34,7 @@ export function startProbe() {
if (result === "ok") { if (result === "ok") {
boot.serverProbeOk = true; boot.serverProbeOk = true;
boot.loginRequired = false; boot.loginRequired = false;
trackingState.bootSync().catch(() => {});
return; return;
} }
@@ -44,6 +46,7 @@ export function startProbe() {
try { try {
await loginBasic(savedUser, savedPass); await loginBasic(savedUser, savedPass);
boot.loginRequired = false; boot.loginRequired = false;
trackingState.bootSync().catch(() => {});
return; return;
} catch {} } catch {}
} }
@@ -82,6 +85,7 @@ export async function submitLogin(onSuccess: () => void) {
boot.loginRequired = false; boot.loginRequired = false;
boot.loginPass = ""; boot.loginPass = "";
boot.loginError = null; boot.loginError = null;
trackingState.bootSync().catch(() => {});
onSuccess(); onSuccess();
} catch (e: any) { } catch (e: any) {
boot.loginError = e?.message ?? "Login failed"; boot.loginError = e?.message ?? "Login failed";
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Manga, Chapter } from "@types";
const APP_ID = "1487894643613106298"; const APP_ID = "1487894643613106298";
const FALLBACK_IMAGE = "moku_logo"; const FALLBACK_IMAGE = "moku_logo";
const BUTTONS = [ const BUTTONS = [
{ label: "GitHub", url: "https://github.com/Youwes09/Moku" }, { label: "GitHub", url: "https://github.com/moku-project/Moku" },
{ label: "Discord", url: "https://discord.gg/Jq3pwuNqPp" }, { label: "Discord", url: "https://discord.gg/Jq3pwuNqPp" },
]; ];
+50 -214
View File
@@ -1,193 +1,29 @@
import type { Manga, Chapter, Category, Source } from "../types"; import type { Manga, Chapter, Category, Source } from "../types";
import { DEFAULT_KEYBINDS, type Keybinds } from "../core/keybinds/defaultBinds"; import type { Settings, ReaderSettings, ReaderPreset, CustomTheme,
import { notifications } from "./notifications.svelte"; LibraryFilter } from "../types/settings";
import { app } from "./app.svelte"; 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 { NavPage } from "./app.svelte";
export type { Toast, ActiveDownload } from "./notifications.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"; const STORE_VERSION = 3;
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 AVG_MIN_PER_CHAPTER = 5; const AVG_MIN_PER_CHAPTER = 5;
const RESET_ON_UPGRADE: (keyof Settings)[] = ["serverBinary", "readerZoom", "uiZoom"]; const RESET_ON_UPGRADE: (keyof Settings)[] = ["serverBinary", "readerZoom", "uiZoom"];
@@ -229,38 +65,38 @@ function mergeSettings(saved: any): Settings {
libraryTabSort: saved?.settings?.libraryTabSort ?? {}, libraryTabSort: saved?.settings?.libraryTabSort ?? {},
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {}, libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {}, libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
extraScanDirs: saved?.settings?.extraScanDirs ?? [], extraScanDirs: saved?.settings?.extraScanDirs ?? [],
pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [], pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [],
readerPresets: saved?.settings?.readerPresets ?? [], readerPresets: saved?.settings?.readerPresets ?? [],
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {}, mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
}; };
} }
class Store { class Store {
settings: Settings = $state(mergeSettings(saved)); settings: Settings = $state(mergeSettings(saved));
activeManga: Manga | null = $state(null); activeManga: Manga | null = $state(null);
previewManga: Manga | null = $state(null); previewManga: Manga | null = $state(null);
activeChapter: Chapter | null = $state(null); activeChapter: Chapter | null = $state(null);
activeChapterList: Chapter[] = $state([]); activeChapterList: Chapter[] = $state([]);
pageUrls: string[] = $state([]); pageUrls: string[] = $state([]);
pageNumber: number = $state(1); pageNumber: number = $state(1);
libraryFilter: LibraryFilter = $state("all"); libraryFilter: LibraryFilter = $state("all");
categories: Category[] = $state([]); categories: Category[] = $state([]);
activeSource: Source | null = $state(null); activeSource: Source | null = $state(null);
libraryTagFilter: string[] = $state([]); libraryTagFilter: string[] = $state([]);
history: HistoryEntry[] = $state(saved?.history ?? []); history: HistoryEntry[] = $state(saved?.history ?? []);
bookmarks: BookmarkEntry[]= $state(saved?.bookmarks ?? []); bookmarks: BookmarkEntry[]= $state(saved?.bookmarks ?? []);
markers: MarkerEntry[] = $state(saved?.markers ?? []); markers: MarkerEntry[] = $state(saved?.markers ?? []);
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []); readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS }); readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
dailyReadCounts: Record<string, number> = $state(saved?.dailyReadCounts ?? {}); dailyReadCounts: Record<string, number> = $state(saved?.dailyReadCounts ?? {});
searchCache: Map<string, any> = $state(new Map()); searchCache: Map<string, any> = $state(new Map());
searchLibraryIds: Set<number> = $state(new Set()); searchLibraryIds: Set<number> = $state(new Set());
searchSrcOffset: number = $state(0); searchSrcOffset: number = $state(0);
readerSessionId: number = $state(0); readerSessionId: number = $state(0);
libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []); libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []);
lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0); lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0);
acknowledgedUpdates: Set<number> = $state(new Set(saved?.acknowledgedUpdateIds ?? [])); acknowledgedUpdates: Set<number> = $state(new Set(saved?.acknowledgedUpdateIds ?? []));
get toasts() { return notifications.toasts; } get toasts() { return notifications.toasts; }
get activeDownloads() { return notifications.activeDownloads; } get activeDownloads() { return notifications.activeDownloads; }
+35
View File
@@ -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;
}
+2
View File
@@ -3,3 +3,5 @@ export * from "./chapter";
export * from "./extension"; export * from "./extension";
export * from "./tracking"; export * from "./tracking";
export * from "./api"; export * from "./api";
export * from "./settings";
export * from "./history";
+156
View File
@@ -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,
};