Compare commits

...

48 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
Youwes09 514910667b Chore: Update Tags for v0.9.0 2026-04-24 21:45:06 -05:00
Youwes09 2e9939c4a9 Feat: Per-Manga Reader Settings + Settings Access (#42 & #46) 2026-04-24 21:09:05 -05:00
Youwes09 581aea5694 Feat: Pin Sources on SourceTab (#48) 2026-04-23 22:37:42 -05:00
Youwes09 72a88b10c8 Feat: Always Display Library Stats & Library Stats Overhaul (#47) 2026-04-23 21:44:11 -05:00
Youwes09 371b4af73f Feat: Settings Button in Reader + Dropdown Overhaul + Settings Listener (#46) 2026-04-23 21:35:33 -05:00
Youwes09 634d32f372 Feat: Download Queue Move to Top/Bottom + Tooltip (#38) 2026-04-23 20:54:57 -05:00
Youwes09 4e6be5d9f5 Feat: Import & Export Store + Update Trigger 2026-04-23 20:09:50 -05:00
Youwes09 bb7256c4f8 Feat: BulkAutomationPanel & Z-Index Issue (#39 & #44) 2026-04-23 16:03:36 -05:00
Youwes09 b12ff4cbaa Fix: Derive Auto-Download List from Filter (#39) 2026-04-23 11:27:54 -05:00
Youwes09 63a829ddca Fix: Chapter Nodes in LibraryUpdater 2026-04-23 10:52:15 -05:00
Youwes09 94b14fb7f6 Fix: Patch Cargo to remove TPU (Windows Installer Patch #41) 2026-04-22 23:33:33 -05:00
Youwes09 bd2fd7a6d7 Fix: Windows Auto-Installer (WIP) 2026-04-23 03:58:20 -05:00
Youwes09 6634ad56d2 Fix: Attempt at Windows-Installer without TPU 2026-04-22 22:25:24 -05:00
Youwes09 2eb8a7662e Feat: Home Re-Design & MacOS Detection Fix 2026-04-22 22:15:13 -05:00
Youwes09 7dd4f52308 Fix: Attempt to Fix Tab Boundaries 2026-04-22 10:57:03 -05:00
Youwes09 690f59c602 Fix: Dark-Theme on Settings Slider 2026-04-22 10:34:09 -05:00
95 changed files with 6350 additions and 2821 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"
+34 -25
View File
@@ -7,6 +7,9 @@ on:
description: "Version to build (e.g. 0.4.0)" description: "Version to build (e.g. 0.4.0)"
required: true required: true
permissions:
contents: write
jobs: jobs:
frontend: frontend:
name: Build frontend name: Build frontend
@@ -40,9 +43,6 @@ jobs:
name: Tauri (macOS) name: Tauri (macOS)
needs: frontend needs: frontend
runs-on: macos-latest runs-on: macos-latest
permissions:
contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -138,7 +138,6 @@ jobs:
run: | run: |
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
# ── aarch64 build ──────────────────────────────────────────────────────
- name: Swap bundle for aarch64 - name: Swap bundle for aarch64
run: | run: |
rm -rf src-tauri/binaries/suwayomi-bundle rm -rf src-tauri/binaries/suwayomi-bundle
@@ -148,16 +147,8 @@ jobs:
- name: Build Tauri app (aarch64) - name: Build Tauri app (aarch64)
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
env: env:
# Ad-hoc signing ("-") ships without a Developer ID.
# Gatekeeper will quarantine the app on other Macs — users must run:
# xattr -rd com.apple.quarantine Moku.app
# To fix this properly, set APPLE_SIGNING_IDENTITY to your
# "Developer ID Application: ..." cert name and add
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID /
# APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD secrets for notarisation.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
# ── x86_64 build ───────────────────────────────────────────────────────
- name: Swap bundle for x86_64 - name: Swap bundle for x86_64
run: | run: |
rm -rf src-tauri/binaries/suwayomi-bundle rm -rf src-tauri/binaries/suwayomi-bundle
@@ -169,17 +160,35 @@ jobs:
env: env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
# ── upload artifacts ─────────────────────────────────────────────────── - name: Upload macOS artifacts to release
- name: Upload arm64 .dmg env:
uses: actions/upload-artifact@v4 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: VERSION: ${{ github.event.inputs.version }}
name: moku-macos-arm64-${{ github.event.inputs.version }} run: |
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg # Wait for the Windows workflow to have created the draft release
retention-days: 7 for i in $(seq 1 12); do
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/moku-project/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
if [ -n "$RELEASE_ID" ]; then break; fi
echo "Waiting for release to exist... attempt $i"
sleep 15
done
- name: Upload x64 .dmg if [ -z "$RELEASE_ID" ]; then
uses: actions/upload-artifact@v4 echo "ERROR: Could not find release for v$VERSION after waiting"
with: exit 1
name: moku-macos-x64-${{ github.event.inputs.version }} fi
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
retention-days: 7 echo "Found release ID: $RELEASE_ID"
upload_asset() {
local file="$1"
local name="$2"
echo "Uploading $name..."
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
}
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
+15 -10
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,14 +152,16 @@ jobs:
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with: with:
tagName: v${{ github.event.inputs.version }} tagName: v${{ github.event.inputs.version }}
releaseName: Moku v${{ github.event.inputs.version }} releaseName: Moku v${{ github.event.inputs.version }}
releaseBody: | releaseBody: |
Windows installer for Moku v${{ github.event.inputs.version }}. Moku v${{ github.event.inputs.version }}
Download the `.exe` file below to install or update.
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
**Linux:** Download `moku.flatpak`
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
+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.
+16 -27
View File
@@ -1,5 +1,9 @@
Major Revisions: Major Revisions:
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility) - Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
- Moku-Share allows exporting of Manga
- Compressed Format (Storage)
- Import as Local-Source
- Takes existing Local-Source or Creates Own
Minor Revisions: Minor Revisions:
- Investigate feasibility of Multi-Page Screenshot (Reader) - Investigate feasibility of Multi-Page Screenshot (Reader)
@@ -8,16 +12,12 @@ Minor Revisions:
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off) - Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
Priority Bugs: Priority Bugs:
- Fix Library-Refresh System (TESTING) - Fix Library-Refresh System (TESTING)
General/Misc Bugs: - Suwayomi RESET
- Fix Highlightable Elements - Allow User to Wipe Suwayomi (Scratch)
- Investigate "egl:failed to create dri2 screen" - If Possible, Component based Wipe (Library, Etc)
- Check Fonts/Design on Flatpak
- Fix Delete-All Crash (Deletes All but Cripples App)
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
In-Progress: In-Progress:
@@ -25,26 +25,15 @@ In-Progress:
- Working on 3D Display Cards - Working on 3D Display Cards
- Add Flathub Support (Pending Video) - Add Flathub Support (Pending Video)
- QOL Animations & Revamps
- Extensions QOL Animations
- Folders Slide
- Dropdown Formatting (Repositories, Etc)
- Extensions Revamps
- Fix Pill-Shaped Language Filter
- Fix ALL ALL EN Tag Issue
- Search QOL Animations
- Languages Dropdown Animations
- Search Revamps
- Custom Language Selector Modal
- Change Tab Selector to match Extensions & Library Folders (Design)
- Filter Genre should Filter Tags as well
- Tracking Revamp
- Completely Revamp Tracking
- Fix Search Folder Tabs (Right-Align) - Fix Tracking Login
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
- Tracking
- Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel)
- Hide Completed from Library Settting
Testing Bugs: Notes from last time:
- Reader Zoom does not work (Dropdown Slider, Value Adjustment); Goes to NaN - Currently working on #42, just need to mount panel and fix button in reader
- Fix Library Folders (Uneven Padding + Bleed into Other Folders); Appears Constraints are Off
-
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.8.0"; version = "0.9.1";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
@@ -149,43 +149,27 @@ EOF
bumpScript = pkgs.writeShellApplication { bumpScript = pkgs.writeShellApplication {
name = "moku-bump"; name = "moku-bump";
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ]; runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain
nodejs_22 pnpm
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ])) ];
text = '' text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; } [[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
VERSION="$1" VERSION="$1"
REPO="$(git rev-parse --show-toplevel)" REPO="$(git rev-parse --show-toplevel)"
echo " Bumping version fields to $VERSION "
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \ sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
"$REPO/src-tauri/tauri.conf.json" "$REPO/src-tauri/tauri.conf.json"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \ sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
"$REPO/src-tauri/Cargo.toml" "$REPO/src-tauri/Cargo.toml"
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \ sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
"$REPO/flake.nix" "$REPO/flake.nix"
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
echo "Done"
echo " Regenerating Cargo.lock "
(cd "$REPO/src-tauri" && cargo generate-lockfile) (cd "$REPO/src-tauri" && cargo generate-lockfile)
echo "Bumped to $VERSION"
'';
};
flatpakScript = pkgs.writeShellApplication {
name = "moku-flatpak";
runtimeInputs = with pkgs; [
gnused coreutils git
nodejs_22 pnpm
appstream flatpak-builder flatpak
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
echo " Bumping versions "
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
"$REPO/src-tauri/tauri.conf.json"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
"$REPO/src-tauri/Cargo.toml"
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
"$REPO/flake.nix"
echo "Done" echo "Done"
echo " Building frontend " echo " Building frontend "
@@ -199,7 +183,15 @@ EOF
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}') FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
echo "sha256: $FRONTEND_SHA" echo "sha256: $FRONTEND_SHA"
echo " Patching manifest sha256 " echo " Regenerating cargo-sources.json "
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
"$REPO/src-tauri/Cargo.lock" \
-o "$REPO/packaging/cargo-sources.json"
echo "Done"
echo " Patching flatpak manifest (version + frontend sha256) "
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF' python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
import re, sys import re, sys
path, sha = sys.argv[1], sys.argv[2] path, sha = sys.argv[1], sys.argv[2]
@@ -213,29 +205,70 @@ EOF
PYEOF PYEOF
echo "Done" echo "Done"
echo " Regenerating cargo-sources.json " echo ""
python3 "$REPO/packaging/flatpak-cargo-generator.py" \ echo "Bumped to v$VERSION"
"$REPO/src-tauri/Cargo.lock" \ echo ""
-o "$REPO/packaging/cargo-sources.json" echo "Commit field in the flatpak manifest still points to the old tag."
echo "After pushing the tag, run:"
echo " nix run .#post-tag-bump -- $VERSION"
'';
};
postTagBumpScript = pkgs.writeShellApplication {
name = "moku-post-tag-bump";
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
PKGBUILD="$REPO/PKGBUILD"
echo " Resolving commit for v$VERSION "
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
| awk '{print $1}')
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
echo "commit: $COMMIT"
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
echo "Done" echo "Done"
echo " Building flatpak " echo " Fetching PKGBUILD tarball sha256 "
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
echo "Done"
echo ""
echo "post-tag-bump complete for v$VERSION"
'';
};
flatpakScript = pkgs.writeShellApplication {
name = "moku-flatpak";
runtimeInputs = with pkgs; [
gnused coreutils git
appstream flatpak-builder flatpak
];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
echo " Building flatpak for v$VERSION "
rm -rf "$REPO/build-dir" "$REPO/repo" rm -rf "$REPO/build-dir" "$REPO/repo"
flatpak-builder \ flatpak-builder \
--repo="$REPO/repo" \ --repo="$REPO/repo" \
--force-clean \ --force-clean \
"$REPO/build-dir" \ "$REPO/build-dir" \
"$MANIFEST" "$MANIFEST"
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.Youwes09.Moku flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
rm -rf "$REPO/build-dir" "$REPO/repo" rm -rf "$REPO/build-dir" "$REPO/repo"
echo "moku.flatpak created"
echo "" echo ""
echo "Done v$VERSION" echo "moku.flatpak created v$VERSION"
echo " -> $REPO/moku.flatpak"
echo ""
echo "After pushing the tag, run:"
echo " nix run .#pkgbuild-bump -- $VERSION"
''; '';
}; };
@@ -249,7 +282,7 @@ EOF
PKGBUILD="$REPO/PKGBUILD" PKGBUILD="$REPO/PKGBUILD"
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; } [[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v$VERSION.tar.gz" TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
echo "Fetching tarball sha256..." echo "Fetching tarball sha256..."
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}') TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
@@ -279,6 +312,7 @@ EOF
default = { type = "app"; program = "${moku}/bin/moku"; }; default = { type = "app"; program = "${moku}/bin/moku"; };
moku = { type = "app"; program = "${moku}/bin/moku"; }; moku = { type = "app"; program = "${moku}/bin/moku"; };
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; }; bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
post-tag-bump = { type = "app"; program = "${postTagBumpScript}/bin/moku-post-tag-bump"; };
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; }; flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; }; pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; }; tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
@@ -300,6 +334,7 @@ EOF
suwayomi-server suwayomi-server
cloudflared cloudflared
xdg-utils xdg-utils
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
]; ];
shellHook = '' shellHook = ''
export NO_STRIP=true export NO_STRIP=true
@@ -308,10 +343,11 @@ EOF
echo "Moku dev shell pnpm install && pnpm tauri:dev" echo "Moku dev shell pnpm install && pnpm tauri:dev"
echo "" echo ""
echo "Release:" echo "Release workflow:"
echo " nix run .#bump -- <ver> bump versions only" echo " nix run .#bump -- <ver> bump all versions + rebuild artifacts"
echo " nix run .#flatpak -- <ver> full flatpak build" echo " git commit && git tag && git push"
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)" echo " nix run .#post-tag-bump -- <ver> patch manifest commit + PKGBUILD sha"
echo " nix run .#flatpak -- <ver> build moku.flatpak"
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)" echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
''; '';
}; };
@@ -1,4 +1,4 @@
app-id: io.github.Youwes09.Moku app-id: io.github.moku_project.Moku
runtime: org.gnome.Platform runtime: org.gnome.Platform
runtime-version: '48' runtime-version: '48'
sdk: org.gnome.Sdk sdk: org.gnome.Sdk
@@ -171,19 +171,19 @@ modules:
- tar -xzf frontend-dist.tar.gz - tar -xzf frontend-dist.tar.gz
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml - . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
- install -Dm755 src-tauri/target/release/moku /app/bin/moku - install -Dm755 src-tauri/target/release/moku /app/bin/moku
- install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop - install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png - install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.png - install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.png - install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
- install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml - install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
sources: sources:
- type: git - type: git
url: https://github.com/Youwes09/Moku.git url: https://github.com/moku-project/Moku.git
tag: v0.8.0 tag: v0.9.1
commit: c573c543187cbd1ca1455b25d6bce0fc62666341 commit: 514910667b0d6e375569a48fb7cef11411d30fbd
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: d547893e1b76f1678df131d46b0964e9ef34e54e8571d5c435a22cef7316f75a sha256: ce773b63c625448df8e128508b46e7e84d2e5cdb1f2b65a6a03f52a4e350b0bf
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+136 -318
View File
@@ -83,19 +83,6 @@
"dest": "cargo/vendor/anyhow-1.0.102", "dest": "cargo/vendor/anyhow-1.0.102",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/arbitrary/arbitrary-1.4.2.crate",
"sha256": "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1",
"dest": "cargo/vendor/arbitrary-1.4.2"
},
{
"type": "inline",
"contents": "{\"package\": \"c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1\", \"files\": {}}",
"dest": "cargo/vendor/arbitrary-1.4.2",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -411,14 +398,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/cc/cc-1.2.60.crate", "url": "https://static.crates.io/crates/cc/cc-1.2.61.crate",
"sha256": "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20", "sha256": "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d",
"dest": "cargo/vendor/cc-1.2.60" "dest": "cargo/vendor/cc-1.2.61"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20\", \"files\": {}}", "contents": "{\"package\": \"d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d\", \"files\": {}}",
"dest": "cargo/vendor/cc-1.2.60", "dest": "cargo/vendor/cc-1.2.61",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -824,19 +811,6 @@
"dest": "cargo/vendor/deranged-0.5.8", "dest": "cargo/vendor/deranged-0.5.8",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/derive_arbitrary/derive_arbitrary-1.4.2.crate",
"sha256": "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a",
"dest": "cargo/vendor/derive_arbitrary-1.4.2"
},
{
"type": "inline",
"contents": "{\"package\": \"1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a\", \"files\": {}}",
"dest": "cargo/vendor/derive_arbitrary-1.4.2",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -1113,14 +1087,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/embed-resource/embed-resource-3.0.8.crate", "url": "https://static.crates.io/crates/embed-resource/embed-resource-3.0.9.crate",
"sha256": "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45", "sha256": "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb",
"dest": "cargo/vendor/embed-resource-3.0.8" "dest": "cargo/vendor/embed-resource-3.0.9"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45\", \"files\": {}}", "contents": "{\"package\": \"c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb\", \"files\": {}}",
"dest": "cargo/vendor/embed-resource-3.0.8", "dest": "cargo/vendor/embed-resource-3.0.9",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -1227,19 +1201,6 @@
"dest": "cargo/vendor/field-offset-0.3.6", "dest": "cargo/vendor/field-offset-0.3.6",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/filetime/filetime-0.2.27.crate",
"sha256": "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db",
"dest": "cargo/vendor/filetime-0.2.27"
},
{
"type": "inline",
"contents": "{\"package\": \"f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db\", \"files\": {}}",
"dest": "cargo/vendor/filetime-0.2.27",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -2205,14 +2166,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/idna_adapter/idna_adapter-1.2.1.crate", "url": "https://static.crates.io/crates/idna_adapter/idna_adapter-1.2.2.crate",
"sha256": "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344", "sha256": "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714",
"dest": "cargo/vendor/idna_adapter-1.2.1" "dest": "cargo/vendor/idna_adapter-1.2.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344\", \"files\": {}}", "contents": "{\"package\": \"cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714\", \"files\": {}}",
"dest": "cargo/vendor/idna_adapter-1.2.1", "dest": "cargo/vendor/idna_adapter-1.2.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -2504,14 +2465,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/libc/libc-0.2.185.crate", "url": "https://static.crates.io/crates/libc/libc-0.2.186.crate",
"sha256": "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f", "sha256": "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66",
"dest": "cargo/vendor/libc-0.2.185" "dest": "cargo/vendor/libc-0.2.186"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f\", \"files\": {}}", "contents": "{\"package\": \"68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66\", \"files\": {}}",
"dest": "cargo/vendor/libc-0.2.185", "dest": "cargo/vendor/libc-0.2.186",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -2722,19 +2683,6 @@
"dest": "cargo/vendor/mime-0.3.17", "dest": "cargo/vendor/mime-0.3.17",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/minisign-verify/minisign-verify-0.2.5.crate",
"sha256": "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e",
"dest": "cargo/vendor/minisign-verify-0.2.5"
},
{
"type": "inline",
"contents": "{\"package\": \"22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e\", \"files\": {}}",
"dest": "cargo/vendor/minisign-verify-0.2.5",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3112,19 +3060,6 @@
"dest": "cargo/vendor/objc2-io-surface-0.3.2", "dest": "cargo/vendor/objc2-io-surface-0.3.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/objc2-osa-kit/objc2-osa-kit-0.3.2.crate",
"sha256": "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0",
"dest": "cargo/vendor/objc2-osa-kit-0.3.2"
},
{
"type": "inline",
"contents": "{\"package\": \"f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0\", \"files\": {}}",
"dest": "cargo/vendor/objc2-osa-kit-0.3.2",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3193,27 +3128,27 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/open/open-5.3.3.crate", "url": "https://static.crates.io/crates/open/open-5.3.4.crate",
"sha256": "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc", "sha256": "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd",
"dest": "cargo/vendor/open-5.3.3" "dest": "cargo/vendor/open-5.3.4"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc\", \"files\": {}}", "contents": "{\"package\": \"9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd\", \"files\": {}}",
"dest": "cargo/vendor/open-5.3.3", "dest": "cargo/vendor/open-5.3.4",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/openssl/openssl-0.10.77.crate", "url": "https://static.crates.io/crates/openssl/openssl-0.10.78.crate",
"sha256": "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f", "sha256": "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222",
"dest": "cargo/vendor/openssl-0.10.77" "dest": "cargo/vendor/openssl-0.10.78"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f\", \"files\": {}}", "contents": "{\"package\": \"f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222\", \"files\": {}}",
"dest": "cargo/vendor/openssl-0.10.77", "dest": "cargo/vendor/openssl-0.10.78",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -3245,14 +3180,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.113.crate", "url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.114.crate",
"sha256": "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644", "sha256": "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6",
"dest": "cargo/vendor/openssl-sys-0.9.113" "dest": "cargo/vendor/openssl-sys-0.9.114"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644\", \"files\": {}}", "contents": "{\"package\": \"13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6\", \"files\": {}}",
"dest": "cargo/vendor/openssl-sys-0.9.113", "dest": "cargo/vendor/openssl-sys-0.9.114",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -3294,19 +3229,6 @@
"dest": "cargo/vendor/os_pipe-1.2.3", "dest": "cargo/vendor/os_pipe-1.2.3",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/osakit/osakit-0.3.1.crate",
"sha256": "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b",
"dest": "cargo/vendor/osakit-0.3.1"
},
{
"type": "inline",
"contents": "{\"package\": \"732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b\", \"files\": {}}",
"dest": "cargo/vendor/osakit-0.3.1",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3648,27 +3570,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/plain/plain-0.2.3.crate", "url": "https://static.crates.io/crates/plist/plist-1.9.0.crate",
"sha256": "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6", "sha256": "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1",
"dest": "cargo/vendor/plain-0.2.3" "dest": "cargo/vendor/plist-1.9.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6\", \"files\": {}}", "contents": "{\"package\": \"092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1\", \"files\": {}}",
"dest": "cargo/vendor/plain-0.2.3", "dest": "cargo/vendor/plist-1.9.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/plist/plist-1.8.0.crate",
"sha256": "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07",
"dest": "cargo/vendor/plist-1.8.0"
},
{
"type": "inline",
"contents": "{\"package\": \"740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07\", \"files\": {}}",
"dest": "cargo/vendor/plist-1.8.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -3869,14 +3778,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/quick-xml/quick-xml-0.38.4.crate", "url": "https://static.crates.io/crates/quick-xml/quick-xml-0.39.2.crate",
"sha256": "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c", "sha256": "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d",
"dest": "cargo/vendor/quick-xml-0.38.4" "dest": "cargo/vendor/quick-xml-0.39.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c\", \"files\": {}}", "contents": "{\"package\": \"958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d\", \"files\": {}}",
"dest": "cargo/vendor/quick-xml-0.38.4", "dest": "cargo/vendor/quick-xml-0.39.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -3973,14 +3882,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rand/rand-0.8.5.crate", "url": "https://static.crates.io/crates/rand/rand-0.8.6.crate",
"sha256": "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404", "sha256": "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a",
"dest": "cargo/vendor/rand-0.8.5" "dest": "cargo/vendor/rand-0.8.6"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404\", \"files\": {}}", "contents": "{\"package\": \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\", \"files\": {}}",
"dest": "cargo/vendor/rand-0.8.5", "dest": "cargo/vendor/rand-0.8.6",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4152,19 +4061,6 @@
"dest": "cargo/vendor/redox_syscall-0.5.18", "dest": "cargo/vendor/redox_syscall-0.5.18",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/redox_syscall/redox_syscall-0.7.4.crate",
"sha256": "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a",
"dest": "cargo/vendor/redox_syscall-0.7.4"
},
{
"type": "inline",
"contents": "{\"package\": \"f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a\", \"files\": {}}",
"dest": "cargo/vendor/redox_syscall-0.7.4",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -4272,14 +4168,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/reqwest/reqwest-0.13.2.crate", "url": "https://static.crates.io/crates/reqwest/reqwest-0.13.3.crate",
"sha256": "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801", "sha256": "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0",
"dest": "cargo/vendor/reqwest-0.13.2" "dest": "cargo/vendor/reqwest-0.13.3"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801\", \"files\": {}}", "contents": "{\"package\": \"62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0\", \"files\": {}}",
"dest": "cargo/vendor/reqwest-0.13.2", "dest": "cargo/vendor/reqwest-0.13.3",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4350,79 +4246,40 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustls/rustls-0.23.38.crate", "url": "https://static.crates.io/crates/rustls/rustls-0.23.39.crate",
"sha256": "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21", "sha256": "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e",
"dest": "cargo/vendor/rustls-0.23.38" "dest": "cargo/vendor/rustls-0.23.39"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21\", \"files\": {}}", "contents": "{\"package\": \"7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e\", \"files\": {}}",
"dest": "cargo/vendor/rustls-0.23.38", "dest": "cargo/vendor/rustls-0.23.39",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustls-native-certs/rustls-native-certs-0.8.3.crate", "url": "https://static.crates.io/crates/rustls-pki-types/rustls-pki-types-1.14.1.crate",
"sha256": "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63", "sha256": "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9",
"dest": "cargo/vendor/rustls-native-certs-0.8.3" "dest": "cargo/vendor/rustls-pki-types-1.14.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63\", \"files\": {}}", "contents": "{\"package\": \"30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9\", \"files\": {}}",
"dest": "cargo/vendor/rustls-native-certs-0.8.3", "dest": "cargo/vendor/rustls-pki-types-1.14.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustls-pki-types/rustls-pki-types-1.14.0.crate", "url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.13.crate",
"sha256": "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd", "sha256": "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e",
"dest": "cargo/vendor/rustls-pki-types-1.14.0" "dest": "cargo/vendor/rustls-webpki-0.103.13"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd\", \"files\": {}}", "contents": "{\"package\": \"61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e\", \"files\": {}}",
"dest": "cargo/vendor/rustls-pki-types-1.14.0", "dest": "cargo/vendor/rustls-webpki-0.103.13",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustls-platform-verifier/rustls-platform-verifier-0.6.2.crate",
"sha256": "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784",
"dest": "cargo/vendor/rustls-platform-verifier-0.6.2"
},
{
"type": "inline",
"contents": "{\"package\": \"1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784\", \"files\": {}}",
"dest": "cargo/vendor/rustls-platform-verifier-0.6.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustls-platform-verifier-android/rustls-platform-verifier-android-0.1.1.crate",
"sha256": "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f",
"dest": "cargo/vendor/rustls-platform-verifier-android-0.1.1"
},
{
"type": "inline",
"contents": "{\"package\": \"f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f\", \"files\": {}}",
"dest": "cargo/vendor/rustls-platform-verifier-android-0.1.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.12.crate",
"sha256": "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06",
"dest": "cargo/vendor/rustls-webpki-0.103.12"
},
{
"type": "inline",
"contents": "{\"package\": \"8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06\", \"files\": {}}",
"dest": "cargo/vendor/rustls-webpki-0.103.12",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5270,19 +5127,6 @@
"dest": "cargo/vendor/tao-macros-0.1.3", "dest": "cargo/vendor/tao-macros-0.1.3",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tar/tar-0.4.45.crate",
"sha256": "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973",
"dest": "cargo/vendor/tar-0.4.45"
},
{
"type": "inline",
"contents": "{\"package\": \"22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973\", \"files\": {}}",
"dest": "cargo/vendor/tar-0.4.45",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -5457,19 +5301,6 @@
"dest": "cargo/vendor/tauri-plugin-shell-2.3.5", "dest": "cargo/vendor/tauri-plugin-shell-2.3.5",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-updater/tauri-plugin-updater-2.10.1.crate",
"sha256": "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af",
"dest": "cargo/vendor/tauri-plugin-updater-2.10.1"
},
{
"type": "inline",
"contents": "{\"package\": \"806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-updater-2.10.1",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -5512,14 +5343,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-winres/tauri-winres-0.3.5.crate", "url": "https://static.crates.io/crates/tauri-winres/tauri-winres-0.3.6.crate",
"sha256": "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0", "sha256": "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6",
"dest": "cargo/vendor/tauri-winres-0.3.5" "dest": "cargo/vendor/tauri-winres-0.3.6"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0\", \"files\": {}}", "contents": "{\"package\": \"cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6\", \"files\": {}}",
"dest": "cargo/vendor/tauri-winres-0.3.5", "dest": "cargo/vendor/tauri-winres-0.3.6",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5694,14 +5525,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tokio/tokio-1.51.1.crate", "url": "https://static.crates.io/crates/tokio/tokio-1.52.1.crate",
"sha256": "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c", "sha256": "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6",
"dest": "cargo/vendor/tokio-1.51.1" "dest": "cargo/vendor/tokio-1.52.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c\", \"files\": {}}", "contents": "{\"package\": \"b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6\", \"files\": {}}",
"dest": "cargo/vendor/tokio-1.51.1", "dest": "cargo/vendor/tokio-1.52.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5782,6 +5613,19 @@
"dest": "cargo/vendor/toml-0.9.12+spec-1.1.0", "dest": "cargo/vendor/toml-0.9.12+spec-1.1.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml/toml-1.1.2+spec-1.1.0.crate",
"sha256": "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee",
"dest": "cargo/vendor/toml-1.1.2+spec-1.1.0"
},
{
"type": "inline",
"contents": "{\"package\": \"81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee\", \"files\": {}}",
"dest": "cargo/vendor/toml-1.1.2+spec-1.1.0",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -6006,14 +5850,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/typenum/typenum-1.19.0.crate", "url": "https://static.crates.io/crates/typenum/typenum-1.20.0.crate",
"sha256": "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb", "sha256": "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de",
"dest": "cargo/vendor/typenum-1.19.0" "dest": "cargo/vendor/typenum-1.20.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb\", \"files\": {}}", "contents": "{\"package\": \"40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de\", \"files\": {}}",
"dest": "cargo/vendor/typenum-1.19.0", "dest": "cargo/vendor/typenum-1.20.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -6214,14 +6058,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/uuid/uuid-1.23.0.crate", "url": "https://static.crates.io/crates/uuid/uuid-1.23.1.crate",
"sha256": "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9", "sha256": "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76",
"dest": "cargo/vendor/uuid-1.23.0" "dest": "cargo/vendor/uuid-1.23.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9\", \"files\": {}}", "contents": "{\"package\": \"ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76\", \"files\": {}}",
"dest": "cargo/vendor/uuid-1.23.0", "dest": "cargo/vendor/uuid-1.23.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -6344,14 +6188,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasip2/wasip2-1.0.2+wasi-0.2.9.crate", "url": "https://static.crates.io/crates/wasip2/wasip2-1.0.3+wasi-0.2.9.crate",
"sha256": "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5", "sha256": "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6",
"dest": "cargo/vendor/wasip2-1.0.2+wasi-0.2.9" "dest": "cargo/vendor/wasip2-1.0.3+wasi-0.2.9"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5\", \"files\": {}}", "contents": "{\"package\": \"20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6\", \"files\": {}}",
"dest": "cargo/vendor/wasip2-1.0.2+wasi-0.2.9", "dest": "cargo/vendor/wasip2-1.0.3+wasi-0.2.9",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -6513,14 +6357,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/web_atoms/web_atoms-0.2.3.crate", "url": "https://static.crates.io/crates/web_atoms/web_atoms-0.2.4.crate",
"sha256": "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576", "sha256": "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538",
"dest": "cargo/vendor/web_atoms-0.2.3" "dest": "cargo/vendor/web_atoms-0.2.4"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576\", \"files\": {}}", "contents": "{\"package\": \"d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538\", \"files\": {}}",
"dest": "cargo/vendor/web_atoms-0.2.3", "dest": "cargo/vendor/web_atoms-0.2.4",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -6552,27 +6396,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/webpki-root-certs/webpki-root-certs-1.0.6.crate", "url": "https://static.crates.io/crates/webpki-roots/webpki-roots-1.0.7.crate",
"sha256": "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca", "sha256": "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d",
"dest": "cargo/vendor/webpki-root-certs-1.0.6" "dest": "cargo/vendor/webpki-roots-1.0.7"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca\", \"files\": {}}", "contents": "{\"package\": \"52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d\", \"files\": {}}",
"dest": "cargo/vendor/webpki-root-certs-1.0.6", "dest": "cargo/vendor/webpki-roots-1.0.7",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/webpki-roots/webpki-roots-1.0.6.crate",
"sha256": "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed",
"dest": "cargo/vendor/webpki-roots-1.0.6"
},
{
"type": "inline",
"contents": "{\"package\": \"22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed\", \"files\": {}}",
"dest": "cargo/vendor/webpki-roots-1.0.6",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -7514,14 +7345,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/winnow/winnow-1.0.1.crate", "url": "https://static.crates.io/crates/winnow/winnow-1.0.2.crate",
"sha256": "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5", "sha256": "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0",
"dest": "cargo/vendor/winnow-1.0.1" "dest": "cargo/vendor/winnow-1.0.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5\", \"files\": {}}", "contents": "{\"package\": \"2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0\", \"files\": {}}",
"dest": "cargo/vendor/winnow-1.0.1", "dest": "cargo/vendor/winnow-1.0.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -7550,6 +7381,19 @@
"dest": "cargo/vendor/wit-bindgen-0.51.0", "dest": "cargo/vendor/wit-bindgen-0.51.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wit-bindgen/wit-bindgen-0.57.1.crate",
"sha256": "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e",
"dest": "cargo/vendor/wit-bindgen-0.57.1"
},
{
"type": "inline",
"contents": "{\"package\": \"1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e\", \"files\": {}}",
"dest": "cargo/vendor/wit-bindgen-0.57.1",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -7667,19 +7511,6 @@
"dest": "cargo/vendor/x11-dl-2.21.0", "dest": "cargo/vendor/x11-dl-2.21.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/xattr/xattr-1.6.1.crate",
"sha256": "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156",
"dest": "cargo/vendor/xattr-1.6.1"
},
{
"type": "inline",
"contents": "{\"package\": \"32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156\", \"files\": {}}",
"dest": "cargo/vendor/xattr-1.6.1",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -7810,19 +7641,6 @@
"dest": "cargo/vendor/zerovec-derive-0.11.3", "dest": "cargo/vendor/zerovec-derive-0.11.3",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/zip/zip-4.6.1.crate",
"sha256": "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1",
"dest": "cargo/vendor/zip-4.6.1"
},
{
"type": "inline",
"contents": "{\"package\": \"caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1\", \"files\": {}}",
"dest": "cargo/vendor/zip-4.6.1",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -2,7 +2,7 @@
Name=Moku Name=Moku
Comment=Manga reader powered by Suwayomi Comment=Manga reader powered by Suwayomi
Exec=moku Exec=moku
Icon=io.github.Youwes09.Moku Icon=io.github.moku_project.Moku
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Graphics;Viewer; Categories=Graphics;Viewer;
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application"> <component type="desktop-application">
<id>io.github.Youwes09.Moku</id> <id>io.github.moku_project.Moku</id>
<metadata_license>MIT</metadata_license> <metadata_license>MIT</metadata_license>
<project_license>MIT</project_license> <project_license>MIT</project_license>
@@ -19,30 +19,30 @@
</p> </p>
</description> </description>
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable> <launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
<url type="homepage">https://github.com/Youwes09/Moku</url> <url type="homepage">https://github.com/moku-project/Moku</url>
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url> <url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
<screenshots> <screenshots>
<screenshot type="default"> <screenshot type="default">
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image> <image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
<caption>Home screen showing your manga library</caption> <caption>Home screen showing your manga library</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image> <image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
<caption>Built-in manga reader</caption> <caption>Built-in manga reader</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image> <image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
<caption>Discover new manga across hundreds of sources</caption> <caption>Discover new manga across hundreds of sources</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image> <image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
<caption>Download manager</caption> <caption>Download manager</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image> <image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
<caption>Settings</caption> <caption>Settings</caption>
</screenshot> </screenshot>
</screenshots> </screenshots>
@@ -54,11 +54,16 @@
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<release version="0.8.0" date="2025-04-01"> <release version="0.9.0" date="2025-04-01">
<description> <description>
<p>Latest release with improved stability and UI refinements.</p> <p>Latest release with improved stability and UI refinements.</p>
</description> </description>
</release> </release>
<release version="0.8.0" date="2025-04-01">
<description>
<p>Old release with improved stability and UI refinements.</p>
</description>
</release>
<release version="0.4.0" date="2025-03-22"> <release version="0.4.0" date="2025-03-22">
<description> <description>
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p> <p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
+88 -268
View File
@@ -47,15 +47,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "atk" name = "atk"
version = "0.18.2" version = "0.18.2"
@@ -268,9 +259,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.60" version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -290,7 +281,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"fnv", "fnv",
"uuid 1.23.0", "uuid 1.23.1",
] ]
[[package]] [[package]]
@@ -584,17 +575,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@@ -678,7 +658,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.5.2", "redox_users 0.5.2",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -810,14 +790,14 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.8" version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
dependencies = [ dependencies = [
"cc", "cc",
"memchr", "memchr",
"rustc_version", "rustc_version",
"toml 0.9.12+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
"vswhom", "vswhom",
"winreg", "winreg",
] ]
@@ -861,7 +841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -889,17 +869,6 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -1732,9 +1701,9 @@ dependencies = [
[[package]] [[package]]
name = "idna_adapter" name = "idna_adapter"
version = "1.2.1" version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [ dependencies = [
"icu_normalizer", "icu_normalizer",
"icu_properties", "icu_properties",
@@ -1969,9 +1938,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.185" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]] [[package]]
name = "libloading" name = "libloading"
@@ -1989,10 +1958,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [ dependencies = [
"bitflags 2.11.1",
"libc", "libc",
"plain",
"redox_syscall 0.7.4",
] ]
[[package]] [[package]]
@@ -2103,12 +2069,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2132,7 +2092,7 @@ dependencies = [
[[package]] [[package]]
name = "moku" name = "moku"
version = "0.8.0" version = "0.9.1"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -2147,7 +2107,6 @@ dependencies = [
"tauri-plugin-os", "tauri-plugin-os",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-shell", "tauri-plugin-shell",
"tauri-plugin-updater",
"tokio", "tokio",
"urlencoding", "urlencoding",
"walkdir", "walkdir",
@@ -2440,18 +2399,6 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2517,9 +2464,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "open" name = "open"
version = "5.3.3" version = "5.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
dependencies = [ dependencies = [
"dunce", "dunce",
"is-wsl", "is-wsl",
@@ -2529,9 +2476,9 @@ dependencies = [
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.77" version = "0.10.78"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"cfg-if", "cfg-if",
@@ -2561,9 +2508,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.113" version = "0.9.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -2600,21 +2547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.45.0", "windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -2660,7 +2593,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall 0.5.18", "redox_syscall",
"smallvec", "smallvec",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -2765,7 +2698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
dependencies = [ dependencies = [
"phf_shared 0.10.0", "phf_shared 0.10.0",
"rand 0.8.5", "rand 0.8.6",
] ]
[[package]] [[package]]
@@ -2775,7 +2708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
"rand 0.8.5", "rand 0.8.6",
] ]
[[package]] [[package]]
@@ -2876,17 +2809,11 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]] [[package]]
name = "plist" name = "plist"
version = "1.8.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.14.0", "indexmap 2.14.0",
@@ -3034,9 +2961,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -3133,9 +3060,9 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha 0.3.1", "rand_chacha 0.3.1",
@@ -3262,15 +3189,6 @@ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
] ]
[[package]]
name = "redox_syscall"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [
"bitflags 2.11.1",
]
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.4.6" version = "0.4.6"
@@ -3392,9 +3310,9 @@ dependencies = [
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.2" version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
@@ -3404,20 +3322,15 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3492,14 +3405,14 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.38" version = "0.23.39"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring", "ring",
@@ -3509,60 +3422,21 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.0" version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time", "web-time",
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.12" version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -3611,7 +3485,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"url", "url",
"uuid 1.23.0", "uuid 1.23.1",
] ]
[[package]] [[package]]
@@ -4001,7 +3875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4019,7 +3893,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core", "objc2-quartz-core",
"raw-window-handle", "raw-window-handle",
"redox_syscall 0.5.18", "redox_syscall",
"tracing", "tracing",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
@@ -4292,17 +4166,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -4339,7 +4202,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest 0.13.2", "reqwest 0.13.3",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -4405,7 +4268,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"time", "time",
"url", "url",
"uuid 1.23.0", "uuid 1.23.1",
"walkdir", "walkdir",
] ]
@@ -4572,39 +4435,6 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.10.1" version = "2.10.1"
@@ -4690,19 +4520,19 @@ dependencies = [
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
"url", "url",
"urlpattern", "urlpattern",
"uuid 1.23.0", "uuid 1.23.1",
"walkdir", "walkdir",
] ]
[[package]] [[package]]
name = "tauri-winres" name = "tauri-winres"
version = "0.3.5" version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
dependencies = [ dependencies = [
"dunce", "dunce",
"embed-resource", "embed-resource",
"toml 0.9.12+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
] ]
[[package]] [[package]]
@@ -4715,7 +4545,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4837,9 +4667,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.51.1" version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -4921,6 +4751,21 @@ dependencies = [
"winnow 0.7.15", "winnow 0.7.15",
] ]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap 2.14.0",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.2",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.6.3" version = "0.6.3"
@@ -4981,7 +4826,7 @@ dependencies = [
"indexmap 2.14.0", "indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow 1.0.1", "winnow 1.0.2",
] ]
[[package]] [[package]]
@@ -4990,7 +4835,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [ dependencies = [
"winnow 1.0.1", "winnow 1.0.2",
] ]
[[package]] [[package]]
@@ -5099,9 +4944,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]] [[package]]
name = "unic-char-property" name = "unic-char-property"
@@ -5222,9 +5067,9 @@ dependencies = [
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.0" version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"js-sys", "js-sys",
@@ -5303,11 +5148,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.2+wasi-0.2.9" version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [ dependencies = [
"wit-bindgen", "wit-bindgen 0.57.1",
] ]
[[package]] [[package]]
@@ -5316,7 +5161,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [ dependencies = [
"wit-bindgen", "wit-bindgen 0.51.0",
] ]
[[package]] [[package]]
@@ -5443,9 +5288,9 @@ dependencies = [
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.3" version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [ dependencies = [
"phf 0.13.1", "phf 0.13.1",
"phf_codegen 0.13.1", "phf_codegen 0.13.1",
@@ -5497,20 +5342,11 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.6" version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@@ -5573,7 +5409,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6132,9 +5968,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.1" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -6158,6 +5994,12 @@ dependencies = [
"wit-bindgen-rust-macro", "wit-bindgen-rust-macro",
] ]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "wit-bindgen-core" name = "wit-bindgen-core"
version = "0.51.0" version = "0.51.0"
@@ -6308,16 +6150,6 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"
@@ -6421,18 +6253,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+1 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.8.0" version = "0.9.1"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -17,7 +17,6 @@ tauri-build = { version = "2.0", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.0", features = [] } tauri = { version = "2.0", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
@@ -0,0 +1,112 @@
#!/bin/sh
# Moku — Suwayomi launcher for Linux AppImage/deb.
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
set -e
# ── Locate our resource directory ─────────────────────────────────────────────
# In an AppImage: resources sit at <mountpoint>/resources/
# In a deb install: /usr/lib/moku/resources/ (Tauri's default)
# We resolve relative to this script's own location.
SELF="$0"
while [ -L "$SELF" ]; do
SELF="$(readlink "$SELF")"
done
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
# Tauri places resources one level up from the binary on Linux.
# Try a few candidates so this works in both AppImage and installed layouts.
find_resource() {
for candidate in \
"${SCRIPT_DIR}" \
"${SCRIPT_DIR}/../resources" \
"${SCRIPT_DIR}/resources"
do
if [ -f "${candidate}/Suwayomi-Server.jar" ]; then
echo "$(cd "$candidate" && pwd)"
return 0
fi
done
return 1
}
RESOURCE_DIR=$(find_resource) || {
echo "[launcher] ERROR: cannot locate Suwayomi-Server.jar relative to $SCRIPT_DIR" >&2
exit 1
}
JAR="${RESOURCE_DIR}/Suwayomi-Server.jar"
JAVA="${RESOURCE_DIR}/jre/bin/java"
CATCH_ABORT="${RESOURCE_DIR}/catch_abort.so"
echo "[launcher] RESOURCE_DIR=$RESOURCE_DIR" >&2
echo "[launcher] JAVA=$JAVA" >&2
echo "[launcher] JAR=$JAR" >&2
if [ ! -x "$JAVA" ]; then
echo "[launcher] ERROR: java not executable at $JAVA" >&2
exit 1
fi
if [ ! -f "$JAR" ]; then
echo "[launcher] ERROR: jar not found at $JAR" >&2
exit 1
fi
# ── Data directory ─────────────────────────────────────────────────────────────
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
mkdir -p "$DATA_DIR"
# ── Seed server.conf on first run ──────────────────────────────────────────────
if [ ! -f "$DATA_DIR/server.conf" ]; then
cat > "$DATA_DIR/server.conf" << 'EOF'
server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "stable"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
EOF
fi
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
# Append keys if absent (e.g. user-managed conf missing them)
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
# ── Suppress any GUI environment that would confuse the JVM ───────────────────
unset DISPLAY
unset WAYLAND_DISPLAY
export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
# ── LD_PRELOAD catch_abort.so if present ──────────────────────────────────────
# Catches SIGTRAP/SIGILL from KCEF/Webview so a bad extension can't
# bring down the whole server process (mirrors the Flatpak build).
if [ -f "$CATCH_ABORT" ]; then
export LD_PRELOAD="${CATCH_ABORT}${LD_PRELOAD:+:$LD_PRELOAD}"
fi
exec "$JAVA" \
-Djava.awt.headless=true \
-Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar "$JAR"
-3
View File
@@ -26,9 +26,6 @@
"core:window:allow-inner-position", "core:window:allow-inner-position",
"core:window:allow-outer-position", "core:window:allow-outer-position",
"core:window:allow-scale-factor", "core:window:allow-scale-factor",
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install",
"process:default", "process:default",
"process:allow-restart", "process:allow-restart",
"http:default", "http:default",
+133 -37
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")))]
{ {
@@ -405,17 +405,14 @@ fn resolve_server_binary(
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// Root of Moku.app/Contents/ — scan every subdirectory level by level.
let resource_dir = app.path().resource_dir().unwrap_or_default(); let resource_dir = app.path().resource_dir().unwrap_or_default();
let contents_dir = resource_dir let contents_dir = resource_dir
.parent() // Moku.app/Contents/ .parent()
.unwrap_or(&resource_dir) .unwrap_or(&resource_dir)
.to_path_buf(); .to_path_buf();
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir)); do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
// Native-binary names we recognise (most specific first so arch-specific
// names win over the generic "suwayomi-server" if both somehow exist).
const NATIVE_NAMES: &[&str] = &[ const NATIVE_NAMES: &[&str] = &[
"suwayomi-server-aarch64-apple-darwin", "suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin", "suwayomi-server-x86_64-apple-darwin",
@@ -425,11 +422,8 @@ fn resolve_server_binary(
"tachidesk-server", "tachidesk-server",
]; ];
// Collect every directory inside Contents/, grouped by depth so we
// search shallower levels first (BFS order via WalkDir min/max_depth).
// We go up to depth 8 which is more than enough for any real bundle.
let mut found_binary: Option<ServerInvocation> = None; let mut found_binary: Option<ServerInvocation> = None;
let mut found_java: Option<(PathBuf, PathBuf)> = None; // (java_exe, jar) let mut found_java: Option<(PathBuf, PathBuf)> = None;
'outer: for depth in 0u8..=8 { 'outer: for depth in 0u8..=8 {
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir) let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
@@ -444,7 +438,6 @@ fn resolve_server_binary(
for dir in &entries { for dir in &entries {
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir)); do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
// 1. Look for a native server binary in this directory.
for name in NATIVE_NAMES { for name in NATIVE_NAMES {
let p = dir.join(name); let p = dir.join(name);
if p.exists() { if p.exists() {
@@ -458,15 +451,10 @@ fn resolve_server_binary(
} }
} }
// 2. Look for a JRE java binary paired with a .jar in the same
// or sibling directories. We record the first hit and keep
// scanning natives; if no native is ever found we fall back
// to this.
if found_java.is_none() { if found_java.is_none() {
let java_exe = dir.join("bin").join("java"); let java_exe = dir.join("bin").join("java");
if java_exe.exists() { if java_exe.exists() {
do_log(log, &format!("[resolve] found java: {:?}", java_exe)); do_log(log, &format!("[resolve] found java: {:?}", java_exe));
// Search upward from the JRE dir for a .jar file.
let mut search = dir.as_path(); let mut search = dir.as_path();
'jar: for _ in 0..5 { 'jar: for _ in 0..5 {
if let Ok(rd) = std::fs::read_dir(search) { if let Ok(rd) = std::fs::read_dir(search) {
@@ -479,7 +467,6 @@ fn resolve_server_binary(
} }
} }
} }
// Also look in a sibling `bin/` directory.
let bin_sibling = search.join("bin"); let bin_sibling = search.join("bin");
if let Ok(rd) = std::fs::read_dir(&bin_sibling) { if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
for entry in rd.filter_map(|e| e.ok()) { for entry in rd.filter_map(|e| e.ok()) {
@@ -602,7 +589,7 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let resp = client let resp = client
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30") .get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
.send() .send()
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -634,32 +621,62 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
#[tauri::command] #[tauri::command]
#[allow(unused_variables)] #[allow(unused_variables)]
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> { async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
return Err("Native install is Windows-only; open the GitHub release page instead.".into()); return Err("Native install is Windows-only; open the GitHub release page instead.".into());
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_http::reqwest;
use std::io::Write;
let updater = app.updater().map_err(|e| e.to_string())?; let client = reqwest::Client::builder()
let update = updater.check().await.map_err(|e| e.to_string())?; .user_agent("Moku")
.build()
let Some(update) = update else {
return Err("No update available.".into());
};
let app_clone = app.clone();
update
.download_and_install(
move |downloaded, total| {
let _ = app_clone.emit("update-progress", UpdateProgress { downloaded: downloaded as u64, total });
},
|| {},
)
.await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let url = format!("https://api.github.com/repos/moku-project/Moku/releases/tags/{}", tag);
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
}
#[derive(serde::Deserialize)]
struct Asset { name: String, browser_download_url: String, size: u64 }
#[derive(serde::Deserialize)]
struct Release { assets: Vec<Asset> }
let body = resp.text().await.map_err(|e| e.to_string())?;
let release: Release = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let asset = release.assets
.into_iter()
.find(|a| a.name.ends_with("_x64-setup.exe"))
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
let total = if asset.size > 0 { Some(asset.size) } else { None };
let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?;
let tmp_path = std::env::temp_dir().join(&asset.name);
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
let mut downloaded: u64 = 0;
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as u64;
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
}
drop(file);
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
std::process::Command::new(&tmp_path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
let _ = app.emit("update-launching", ());
Ok(()) Ok(())
} }
} }
@@ -698,7 +715,6 @@ fn open_path(path: String) -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> { async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
@@ -709,6 +725,83 @@ async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
.map(|p| p.to_string()) .map(|p| p.to_string())
} }
fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf {
app.path().app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("backups")
}
#[tauri::command]
async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = format!("moku-backup-{}.json", now);
let path = app.dialog()
.file()
.set_title("Save Moku app data backup")
.set_file_name(&filename)
.blocking_save_file()
.ok_or("Cancelled")?;
let dest = PathBuf::from(path.to_string());
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
Ok(dest.to_string_lossy().into_owned())
}
#[tauri::command]
async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
let path = app.dialog()
.file()
.set_title("Open Moku app data backup")
.blocking_pick_file()
.ok_or("Cancelled")?;
let src = PathBuf::from(path.to_string());
let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
Ok(contents)
}
#[tauri::command]
fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
let backup_dir = moku_backup_dir(&app);
std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now));
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-"))
.collect();
entries.sort_by_key(|e| e.file_name());
for old in entries.iter().take(entries.len().saturating_sub(5)) {
let _ = std::fs::remove_file(old.path());
}
Ok(())
}
#[tauri::command]
fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
moku_backup_dir(&app).to_string_lossy().into_owned()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@@ -718,7 +811,6 @@ pub fn run() {
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(ServerState(Mutex::new(None))) .manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_storage_info, get_storage_info,
@@ -734,6 +826,10 @@ pub fn run() {
restart_app, restart_app,
open_path, open_path,
pick_downloads_folder, pick_downloads_folder,
export_app_data,
import_app_data,
auto_backup_app_data,
get_auto_backup_dir,
]) ])
.setup(|_app| Ok(())) .setup(|_app| Ok(()))
.on_window_event(|window, event| { .on_window_event(|window, event| {
+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.8.0", "version": "0.9.1",
"identifier": "io.github.Youwes09.Moku.app", "identifier": "io.github.MokuProject.Moku",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
"beforeBuildCommand": "pnpm build" "beforeBuildCommand": "pnpm build"
@@ -27,9 +27,7 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": [ "targets": ["nsis"],
"nsis"
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@@ -49,10 +47,6 @@
"plugins": { "plugins": {
"shell": { "shell": {
"open": true "open": true
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
"endpoints": []
} }
} }
} }
+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

+9 -3
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(2000), signal: timeoutSignal(5000),
}); });
if (res.ok) return "ok"; if (res.ok) return "ok";
if (res.status === 401) { if (res.status === 401) {
@@ -76,4 +82,4 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
} }
return "unreachable"; return "unreachable";
} catch { return "unreachable"; } } catch { return "unreachable"; }
} }
+38
View File
@@ -0,0 +1,38 @@
import { invoke } from "@tauri-apps/api/core";
function collectAppData(): Record<string, string> {
const data: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key !== null) data[key] = localStorage.getItem(key) ?? "";
}
return data;
}
function applyAppData(data: Record<string, string>): void {
localStorage.clear();
for (const [key, value] of Object.entries(data)) {
localStorage.setItem(key, value);
}
}
export async function exportAppData(): Promise<void> {
const json = JSON.stringify(collectAppData(), null, 2);
await invoke("export_app_data", { json });
}
export async function importAppData(): Promise<void> {
const json = await invoke<string>("import_app_data");
const data: Record<string, string> = JSON.parse(json);
applyAppData(data);
location.reload();
}
export async function autoBackupAppData(): Promise<void> {
try {
const json = JSON.stringify(collectAppData());
await invoke("auto_backup_app_data", { json });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
+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;
}
@@ -30,17 +30,10 @@
if (!tabsEl) return; if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive"); const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
if (!active) return; if (!active) return;
const containerLeft = tabsEl.getBoundingClientRect().left; tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
tabIndicator = {
left: active.getBoundingClientRect().left - containerLeft,
width: active.offsetWidth,
};
} }
$effect(() => { $effect(() => { tab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
tab; // reactive on tab change
if (anims) requestAnimationFrame(updateIndicator);
});
const SEARCH_PAGES = 3; const SEARCH_PAGES = 3;
const SEARCH_LIMIT = 200; const SEARCH_LIMIT = 200;
@@ -5,6 +5,8 @@
import { shouldHideNsfw, shouldHideSource } from "@core/util"; import { shouldHideNsfw, shouldHideSource } from "@core/util";
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte";
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
import type { Manga, Source } from "@types"; import type { Manga, Source } from "@types";
interface Props { interface Props {
@@ -28,6 +30,17 @@
let src_currentPage = $state(1); let src_currentPage = $state(1);
let src_abortCtrl: AbortController | null = null; let src_abortCtrl: AbortController | null = null;
let ctx_x = $state(0);
let ctx_y = $state(0);
let ctx_source: Source | null = $state(null);
const pinnedIds = $derived(store.settings.pinnedSourceIds ?? []);
const pinnedSources = $derived(
pinnedIds
.map(id => allSources.find(s => s.id === id))
.filter((s): s is Source => !!s)
);
$effect(() => { $effect(() => {
if (!allSources.length) return; if (!allSources.length) return;
const langs = new Set(allSources.map((s) => s.lang)); const langs = new Set(allSources.map((s) => s.lang));
@@ -93,11 +106,16 @@
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR"); if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
} }
function openCtx(e: MouseEvent, src: Source) {
e.preventDefault();
ctx_x = e.clientX; ctx_y = e.clientY; ctx_source = src;
}
function closeCtx() { ctx_source = null; }
onDestroy(() => { src_abortCtrl?.abort(); }); onDestroy(() => { src_abortCtrl?.abort(); });
</script> </script>
<div class="splitRoot"> <div class="splitRoot">
<div class="splitSidebar"> <div class="splitSidebar">
<div class="srcLangRow"> <div class="srcLangRow">
<span class="langPocketLabel">Language</span> <span class="langPocketLabel">Language</span>
@@ -122,6 +140,7 @@
class="splitItem splitItemSource" class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === localSource.id} class:splitItemActive={src_activeSource?.id === localSource.id}
onclick={() => srcSelectSource(localSource)} onclick={() => srcSelectSource(localSource)}
oncontextmenu={(e) => openCtx(e, localSource)}
> >
<div class="localSourceIcon"> <div class="localSourceIcon">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"> <svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
@@ -132,11 +151,34 @@
</button> </button>
<div class="localDivider"></div> <div class="localDivider"></div>
{/if} {/if}
{#if pinnedSources.length > 0}
<p class="sectionLabel">Pinned</p>
{#each pinnedSources as src (src.id)}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(src)}
oncontextmenu={(e) => openCtx(e, src)}
>
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span>
<span class="pinIndicator" title="Pinned">
<PushPin size={9} weight="fill" />
</span>
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{/each}
<div class="localDivider"></div>
<p class="sectionLabel">All Sources</p>
{/if}
{#each src_visibleSources as src (src.id)} {#each src_visibleSources as src (src.id)}
<button <button
class="splitItem splitItemSource" class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id} class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(src)} onclick={() => srcSelectSource(src)}
oncontextmenu={(e) => openCtx(e, src)}
> >
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> <Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span> <span class="splitItemLabel">{src.name}</span>
@@ -235,6 +277,28 @@
</div> </div>
</div> </div>
{#if ctx_source}
{@const isPinned = pinnedIds.includes(ctx_source.id)}
<ContextMenu
x={ctx_x}
y={ctx_y}
onClose={closeCtx}
items={[
{
label: isPinned ? "Unpin source" : "Pin source",
icon: isPinned ? PushPinSlash : PushPin,
onClick: () => { store.togglePinnedSource(ctx_source!.id); },
},
{ separator: true },
{
label: "Browse source",
icon: ArrowRight,
onClick: () => { srcSelectSource(ctx_source!); },
},
]}
/>
{/if}
<style> <style>
.splitRoot { flex: 1; display: flex; overflow: hidden; } .splitRoot { flex: 1; display: flex; overflow: hidden; }
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; } .splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
@@ -256,6 +320,8 @@
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); } .splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; } .splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
.sectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-2) var(--sp-3) var(--sp-1); margin: 0; }
.pinIndicator { display: flex; align-items: center; color: var(--accent-fg); opacity: 0.7; flex-shrink: 0; margin-left: auto; margin-right: 2px; }
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; } .sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; }
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180,60,60,0.08)); border: 1px solid rgba(180,60,60,0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; } .nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180,60,60,0.08)); border: 1px solid rgba(180,60,60,0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
@@ -1,37 +1,22 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, ArrowUp, ArrowDown, ArrowClockwise, X } from "phosphor-svelte"; import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte";
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
import type { DownloadQueueItem } from "@types/index"; import type { DownloadQueueItem } from "@types/index";
import { pageProgress } from "../lib/downloadQueue"; import { pageProgress } from "../lib/downloadQueue";
interface Props { interface Props {
item: DownloadQueueItem; item: DownloadQueueItem;
index: number; isActive: boolean;
isActive: boolean; 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;
onSelect: (chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
onBatchRemove: () => void;
onBatchRetry: () => void;
onBatchReorder: (dir: "up" | "down") => void;
onClearSelect: () => void;
} }
const { const {
item, index, isActive, isFirst, isLast, isRemoving, item, isActive, isRemoving, isSelected,
isSelected, selectedCount, selectedErrorCount, batchWorking, onRemove, onRetry, onSelect,
onRemove, onRetry, onReorder, onSelect,
onBatchRemove, onBatchRetry, onBatchReorder, onClearSelect,
}: Props = $props(); }: Props = $props();
const manga = $derived(item.chapter.manga); const manga = $derived(item.chapter.manga);
@@ -39,118 +24,6 @@
const prog = $derived(pageProgress(item.progress, pages)); const prog = $derived(pageProgress(item.progress, pages));
const isError = $derived(item.state === "ERROR"); const isError = $derived(item.state === "ERROR");
const pct = $derived(Math.round(item.progress * 100)); const pct = $derived(Math.round(item.progress * 100));
let menuX = $state(0);
let menuY = $state(0);
let menuOpen = $state(false);
function openMenu(e: MouseEvent) {
e.preventDefault();
menuX = e.clientX;
menuY = e.clientY;
menuOpen = true;
}
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let touchMoved = false;
function onTouchStart(e: TouchEvent) {
touchMoved = false;
const touch = e.touches[0];
longPressTimer = setTimeout(() => {
longPressTimer = null;
if (touchMoved) return;
if (selectedCount === 0) {
onSelect(item.chapter.id, { shiftKey: false, ctrlKey: false, metaKey: false });
} else {
menuX = touch.clientX;
menuY = touch.clientY;
menuOpen = true;
}
}, 500);
}
function onTouchMove() {
touchMoved = true;
cancelLongPress();
}
function cancelLongPress() {
if (longPressTimer !== null) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
const menuItems = $derived.by<MenuEntry[]>(() => {
const inBatch = isSelected && selectedCount > 1;
const entries: MenuEntry[] = [];
if (inBatch) {
entries.push({
label: `Move up (${selectedCount})`,
icon: ArrowUp,
onClick: () => onBatchReorder("up"),
disabled: batchWorking,
});
entries.push({
label: `Move down (${selectedCount})`,
icon: ArrowDown,
onClick: () => onBatchReorder("down"),
disabled: batchWorking,
});
entries.push({ separator: true });
if (selectedErrorCount > 0) {
entries.push({
label: `Retry errors (${selectedErrorCount})`,
icon: ArrowClockwise,
onClick: onBatchRetry,
disabled: batchWorking,
});
}
entries.push({
label: `Remove selected (${selectedCount})`,
icon: X,
onClick: onBatchRemove,
danger: true,
disabled: batchWorking,
});
entries.push({ separator: true });
entries.push({ label: "Deselect all", onClick: onClearSelect });
} else {
if (isError) {
entries.push({
label: "Retry",
icon: ArrowClockwise,
onClick: () => onRetry(item.chapter.id),
disabled: isRemoving,
});
entries.push({ separator: true });
}
entries.push({
label: "Move up",
icon: ArrowUp,
onClick: () => onReorder(item.chapter.id, "up"),
disabled: isFirst || isActive,
});
entries.push({
label: "Move down",
icon: ArrowDown,
onClick: () => onReorder(item.chapter.id, "down"),
disabled: isLast || isActive,
});
entries.push({ separator: true });
entries.push({
label: "Remove",
icon: X,
onClick: () => onRemove(item.chapter.id),
danger: true,
disabled: isRemoving || isActive,
});
}
return entries;
});
</script> </script>
<div <div
@@ -160,10 +33,6 @@
class:row-selected={isSelected} class:row-selected={isSelected}
class:row-removing={isRemoving} class:row-removing={isRemoving}
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }} onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
oncontextmenu={openMenu}
ontouchstart={onTouchStart}
ontouchend={cancelLongPress}
ontouchmove={onTouchMove}
> >
{#if manga?.thumbnailUrl} {#if manga?.thumbnailUrl}
<div class="thumb"> <div class="thumb">
@@ -201,12 +70,6 @@
</button> </button>
{/if} {/if}
{#if !isActive} {#if !isActive}
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "up"); }} disabled={isFirst} title="Move up">
<ArrowUp size={11} weight="light" />
</button>
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "down"); }} disabled={isLast} title="Move down">
<ArrowDown size={11} weight="light" />
</button>
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove"> <button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if} {#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button> </button>
@@ -215,10 +78,6 @@
</div> </div>
</div> </div>
{#if menuOpen}
<ContextMenu x={menuX} y={menuY} items={menuItems} onClose={() => (menuOpen = false)} />
{/if}
<style> <style>
.row { .row {
display: flex; display: flex;
@@ -235,118 +94,33 @@
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
.row:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
.row.row-active { border-color: var(--accent-dim); } .row.row-active { border-color: var(--accent-dim); }
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); } .row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); } .row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
.row.row-removing { opacity: 0.4; pointer-events: none; } .row.row-removing { opacity: 0.4; pointer-events: none; }
.thumb { .thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
width: 36px;
height: 54px;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-overlay);
flex-shrink: 0;
border: 1px solid var(--border-dim);
}
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; } :global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
.info { .info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
min-width: 0;
}
.manga-title { .manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
font-size: var(--text-sm); .chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
font-weight: var(--weight-medium);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chapter-name { .progress-row { display: flex; align-items: center; gap: var(--sp-2); }
font-size: var(--text-xs); .progress-wrap { flex: 1; height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
color: var(--text-muted); .progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; opacity: 0.5; }
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-row {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.progress-wrap {
flex: 1;
height: 2px;
background: var(--border-base);
border-radius: var(--radius-full);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.4s ease;
opacity: 0.5;
}
.row-active .progress-bar { opacity: 1; } .row-active .progress-bar { opacity: 1; }
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; } .progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
.pages-label { .row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
font-family: var(--font-ui); .state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
white-space: nowrap;
}
.row-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-1);
flex-shrink: 0;
}
.state-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.state-label.state-error { color: var(--color-error); opacity: 0.8; } .state-label.state-error { color: var(--color-error); opacity: 0.8; }
.actions { .actions { display: flex; align-items: center; gap: 2px; }
display: flex; .action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base), background var(--t-base); }
align-items: center;
gap: 2px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--t-base), background var(--t-base);
}
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); } .action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
.action-btn:disabled { opacity: 0.25; cursor: default; } .action-btn:disabled { opacity: 0.25; cursor: default; }
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); } .action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
@@ -4,31 +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;
onSelect: (chapterId: number, e: MouseEvent) => void; onSelect: (chapterId: number, e: MouseEvent) => void;
onClearSelect: () => void;
onBatchRemove: () => void;
onBatchRetry: () => void;
onBatchReorder: (dir: "up" | "down") => void;
} }
const { const {
queue, loading, isRunning, dequeueing, selected, batchWorking, queue, loading, isRunning, dequeueing, selected,
onRemove, onRetry, onReorder, onSelect, onClearSelect, onRemove, onRetry, onReorder, onReorderEdge, onSelect,
onBatchRemove, onBatchRetry, onBatchReorder,
}: Props = $props(); }: Props = $props();
const selectedErrorCount = $derived(
queue.filter((i) => selected.has(i.chapter.id) && i.state === "ERROR").length,
);
</script> </script>
{#if loading} {#if loading}
@@ -42,43 +33,20 @@
{#each queue as item, i (item.chapter.id)} {#each queue as item, i (item.chapter.id)}
<DownloadItem <DownloadItem
{item} {item}
index={i}
isActive={i === 0 && isRunning} isActive={i === 0 && isRunning}
isFirst={i === 0}
isLast={i === queue.length - 1}
isRemoving={dequeueing.has(item.chapter.id)} isRemoving={dequeueing.has(item.chapter.id)}
isSelected={selected.has(item.chapter.id)} isSelected={selected.has(item.chapter.id)}
selectedCount={selected.size}
{selectedErrorCount}
{batchWorking}
{onRemove} {onRemove}
{onRetry} {onRetry}
{onReorder} {onReorder}
{onReorderEdge}
{onSelect} {onSelect}
{onClearSelect}
{onBatchRemove}
{onBatchRetry}
{onBatchReorder}
/> />
{/each} {/each}
</div> </div>
{/if} {/if}
<style> <style>
.list { .list { display: flex; flex-direction: column; gap: var(--sp-2); }
display: flex; .empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
flex-direction: column;
gap: var(--sp-2);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 160px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
}
</style> </style>
+102 -109
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,145 +114,119 @@
</div> </div>
</div> </div>
<div class="content" onclick={handleClickOff}> <div class="bar-wrap">
<div class="status-bar"> <div class="status-bar" onclick={handleClickOff} role="presentation">
<div class="status-dot" class:active={downloadStore.isRunning}></div> <div class="status-dot" class:active={downloadStore.isRunning}></div>
<span class="status-text"> <span class="status-text">
{downloadStore.togglingPlay {downloadStore.togglingPlay
? (downloadStore.isRunning ? "Pausing…" : "Starting…") ? (downloadStore.isRunning ? "Pausing…" : "Starting…")
: downloadStore.isRunning ? "Downloading" : "Paused"} : downloadStore.isRunning ? "Downloading" : "Paused"}
</span> </span>
<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)}
onSelect={handleSelect} onSelect={handleSelect}
onClearSelect={() => { downloadStore.clearSelection(); selectAnchor = null; }}
onBatchRemove={() => downloadStore.dequeueSelected()}
onBatchRetry={() => downloadStore.retrySelected()}
onBatchReorder={(dir) => downloadStore.reorderSelected(dir)}
/> />
</div> </div>
</div> </div>
<style> <style>
.root { .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.heading {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-normal);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.header-actions { display: flex; gap: var(--sp-2); } .header-actions { display: flex; gap: var(--sp-2); }
.content { .bar-wrap { padding: var(--sp-4) var(--sp-6); flex-shrink: 0; }
flex: 1;
overflow-y: auto;
padding: var(--sp-5) var(--sp-6) var(--sp-6);
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.icon-btn { .status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
display: flex; .status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
align-items: center; .status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
justify-content: center; .status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
width: 28px; .status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
height: 28px; .status-eta { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); opacity: 0.8; }
border-radius: var(--radius-md); .status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
border: 1px solid var(--border-dim);
color: var(--text-muted); .sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
background: none; .status-bar { cursor: default; }
cursor: pointer; .bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
transition: color var(--t-base), border-color var(--t-base), background var(--t-base); .sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
} .sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); white-space: nowrap; }
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } .sel-text-btn:hover { color: var(--text-primary); }
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-action-danger:hover:not(:disabled) { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); }
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); } .icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); } .icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.status-bar { .move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
display: flex; .move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
align-items: center; .move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
gap: var(--sp-3); .move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
padding: var(--sp-3); .move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
background: var(--bg-raised); .move-input:focus { color: var(--text-primary); }
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-faint);
flex-shrink: 0;
transition: background var(--t-base);
}
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
.status-text {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
flex: 1;
letter-spacing: var(--tracking-wide);
}
.status-right {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.status-eta {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--accent-fg);
letter-spacing: var(--tracking-wide);
opacity: 0.8;
}
.status-count {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } } @keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
+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; }
},
};
}
@@ -55,6 +55,16 @@ export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): nu
return remaining / pagesPerSec; return remaining / pagesPerSec;
} }
export function reorderSelectedToEdge(
queue: DownloadQueueItem[],
selected: Set<number>,
edge: "top" | "bottom",
): DownloadQueueItem[] {
const pinned = queue.filter((i) => selected.has(i.chapter.id));
const rest = queue.filter((i) => !selected.has(i.chapter.id));
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
}
export function formatEta(seconds: number): string { export function formatEta(seconds: number): string {
if (seconds < 60) return `~${Math.ceil(seconds)}s`; if (seconds < 60) return `~${Math.ceil(seconds)}s`;
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`; if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
@@ -12,6 +12,7 @@ import {
isRunning, getErrored, calcSpeed, estimateEta, isRunning, getErrored, calcSpeed, estimateEta,
type SpeedSample, type SpeedSample,
} from "../lib/downloadQueue"; } from "../lib/downloadQueue";
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
class DownloadStore { class DownloadStore {
status: DownloadStatus | null = $state(null); status: DownloadStatus | null = $state(null);
@@ -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(); }
@@ -231,6 +261,60 @@ class DownloadStore {
finally { this.batchWorking = false; } finally { this.batchWorking = false; }
} }
async reorderToEdge(chapterId: number, edge: "top" | "bottom") {
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
if (idx === -1) return;
const first = this.isRunning ? 1 : 0;
const last = this.queue.length - 1;
const to = edge === "top" ? first : last;
if (idx === to) return;
const newQueue = [...this.queue];
newQueue.splice(idx, 1);
newQueue.splice(to, 0, this.queue[idx]);
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
this.applyStatus(d.reorderChapterDownload.downloadStatus);
} catch (e) { console.error(e); this.poll(); }
}
async reorderSelectedToEdge(edge: "top" | "bottom") {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const first = this.isRunning ? 1 : 0;
const active = this.queue.slice(0, first);
const moveable = this.queue.slice(first);
const pinned = moveable.filter((i) => this.selected.has(i.chapter.id));
const rest = moveable.filter((i) => !this.selected.has(i.chapter.id));
const newQueue = edge === "top"
? [...active, ...pinned, ...rest]
: [...active, ...rest, ...pinned];
if (this.status) this.status = { ...this.status, queue: newQueue };
const last = this.queue.length - 1;
try {
if (edge === "top") {
for (let i = 0; i < pinned.length; i++) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i },
);
}
} else {
for (let i = 0; i < pinned.length; i++) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) },
);
}
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); } selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
toggleSelect(chapterId: number) { toggleSelect(chapterId: number) {
const next = new Set(this.selected); const next = new Set(this.selected);
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch } from "phosphor-svelte"; import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp } from "phosphor-svelte";
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers"; import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
interface Props { interface Props {
@@ -8,6 +8,7 @@
panel: Panel; panel: Panel;
refreshing: boolean; refreshing: boolean;
updateCount: number; updateCount: number;
updatingAll: boolean;
availableLangs: string[]; availableLangs: string[];
langFilter: string | null; langFilter: string | null;
anims: boolean; anims: boolean;
@@ -18,14 +19,15 @@
onLang: (lang: string | null) => void; onLang: (lang: string | null) => void;
onPanel: (p: Panel) => void; onPanel: (p: Panel) => void;
onRefresh: () => void; onRefresh: () => void;
onUpdateAll: () => void;
} }
let { let {
filter, search, panel, refreshing, updateCount, filter, search, panel, refreshing, updateCount, updatingAll,
availableLangs, langFilter, availableLangs, langFilter,
anims, tabIndicator, anims, tabIndicator,
tabsEl = $bindable(), tabsEl = $bindable(),
onFilter, onSearch, onLang, onPanel, onRefresh, onFilter, onSearch, onLang, onPanel, onRefresh, onUpdateAll,
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -57,6 +59,11 @@
<button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh repo"> <button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh repo">
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} /> <ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button> </button>
{#if updateCount > 0}
<button class="icon-btn update-badge" onclick={onUpdateAll} disabled={updatingAll} title="Update all ({updateCount})">
<ArrowCircleUp size={14} weight="fill" class={updatingAll ? "anim-spin" : ""} />
</button>
{/if}
</div> </div>
</div> </div>
@@ -90,6 +97,8 @@
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); } .search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); } .search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); } .search:focus { border-color: var(--border-strong); }
.icon-btn.update-badge { color: var(--accent-fg); }
.icon-btn.update-badge:hover:not(:disabled) { background: var(--accent-muted); }
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); } .lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); } .lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); } .lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
@@ -18,8 +18,7 @@
if (!tabsEl) return; if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.active"); const active = tabsEl.querySelector<HTMLElement>(".tab.active");
if (!active) return; if (!active) return;
const containerLeft = tabsEl.getBoundingClientRect().left; tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
tabIndicator = { left: active.getBoundingClientRect().left - containerLeft, width: active.offsetWidth };
} }
let extensions: Extension[] = $state([]); let extensions: Extension[] = $state([]);
@@ -30,10 +29,11 @@
let search = $state(""); let search = $state("");
let langFilter = $state<string | null>(null); let langFilter = $state<string | null>(null);
let working = $state(new Set<string>()); let working = $state(new Set<string>());
let updatingAll = $state(false);
let expanded = $state(new Set<string>()); let expanded = $state(new Set<string>());
let panel = $state<Panel>(null); let panel = $state<Panel>(null);
$effect(() => { filter; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); }); $effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
let externalUrl = $state(""); let externalUrl = $state("");
let installing = $state(false); let installing = $state(false);
@@ -125,6 +125,15 @@
} }
} }
async function updateAll() {
const pending = extensions.filter((e) => e.hasUpdate);
if (!pending.length || updatingAll) return;
updatingAll = true;
for (const ext of pending) await mutate(ext.pkgName, "update");
updatingAll = false;
addToast({ kind: "success", title: "All extensions updated", body: `${pending.length} extension${pending.length === 1 ? "" : "s"} updated` });
}
async function installExternal() { async function installExternal() {
const url = externalUrl.trim(); const url = externalUrl.trim();
const err = validateUrl(url, ".apk"); const err = validateUrl(url, ".apk");
@@ -207,13 +216,14 @@
<div class="root anim-fade-in"> <div class="root anim-fade-in">
<ExtensionFilters <ExtensionFilters
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter} {filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
{anims} {tabIndicator} {anims} {tabIndicator} {updatingAll}
bind:tabsEl bind:tabsEl
onFilter={setFilter} onFilter={setFilter}
onSearch={(q) => search = q} onSearch={(q) => search = q}
onLang={(l) => langFilter = l} onLang={(l) => langFilter = l}
onPanel={openPanel} onPanel={openPanel}
onRefresh={fetchFromRepo} onRefresh={fetchFromRepo}
onUpdateAll={updateAll}
/> />
{#if panel === "apk"} {#if panel === "apk"}
@@ -0,0 +1,281 @@
<script lang="ts">
let {
dailyReadCounts,
}: {
dailyReadCounts: Record<string, number>;
} = $props();
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
if (count === 0) return 0;
if (count === 1) return 1;
if (count <= 3) return 2;
if (count <= 6) return 3;
return 4;
}
let tip: { text: string; x: number; y: number } | null = $state(null);
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const label = cell.count === 0
? `No chapters — ${fmtDate(cell.dateStr)}`
: `${cell.count} chapter${cell.count !== 1 ? "s" : ""}${fmtDate(cell.dateStr)}`;
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 };
}
function hideTip() { tip = null; }
function fmtDate(d: string): string {
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
let wrapEl: HTMLElement;
let cellSize = $state(12);
let numWeeks = $state(26);
const GAP = 3;
const DAY_GUTTER = 28;
const LEGEND_H = 20;
const MONTH_H = 14;
const ROWS = 7;
$effect(() => {
if (!wrapEl) return;
const obs = new ResizeObserver(() => {
const h = wrapEl.clientHeight;
const w = wrapEl.clientWidth;
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS));
cellSize = cs;
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)));
});
obs.observe(wrapEl);
return () => obs.disconnect();
});
const visibleWeeks = $derived((() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = today.toISOString().slice(0, 10);
const endDow = today.getDay(); // 0=Sun ... 6=Sat
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = [];
for (let wi = numWeeks - 1; wi >= 0; wi--) {
const week: typeof weeks[0] = [];
for (let di = 0; di < 7; di++) {
const d = new Date(weekEnd);
d.setDate(d.getDate() - wi * 7 - (6 - di));
const dateStr = d.toISOString().slice(0, 10);
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
}
weeks.push(week);
}
return weeks;
})());
const monthLabels = $derived((() => {
const labels: { label: string; colIndex: number }[] = [];
let lastMonth = -1;
visibleWeeks.forEach((week, ci) => {
const first = week[0];
if (!first) return;
const m = new Date(first.dateStr + "T00:00:00").getMonth();
if (m !== lastMonth) {
labels.push({ label: new Date(first.dateStr + "T00:00:00").toLocaleDateString("en-US", { month: "short" }), colIndex: ci });
lastMonth = m;
}
});
return labels;
})());
const DAY_LABELS = ["Sun", "", "Tue", "", "Thu", "", "Sat"];
</script>
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
<div class="month-row">
<div class="day-gutter"></div>
<div class="month-cells">
{#each visibleWeeks as _week, ci}
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
<div class="month-label">{lbl?.label ?? ""}</div>
{/each}
</div>
</div>
<div class="grid-row">
<div class="day-labels">
{#each DAY_LABELS as d}
<span class="day-label">{d}</span>
{/each}
</div>
<div class="cell-grid">
{#each visibleWeeks as week}
<div class="week-col">
{#each week as cell}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<button
class="cell intensity-{intensity(cell.count)}"
class:cell-today={cell.isToday}
class:cell-future={cell.isFuture}
onmouseover={(e) => showTip(e, cell)}
onmouseleave={hideTip}
aria-label="{cell.count} chapters on {cell.dateStr}"
></button>
{/each}
</div>
{/each}
</div>
</div>
<div class="legend">
<span class="legend-label">Less</span>
{#each [0, 1, 2, 3, 4] as lvl}
<div class="legend-cell intensity-{lvl}"></div>
{/each}
<span class="legend-label">More</span>
</div>
</div>
{#if tip}
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
{/if}
<style>
.heatmap-wrap {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
}
.month-row {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.day-gutter { width: 28px; flex-shrink: 0; }
.month-cells {
display: grid;
grid-template-columns: repeat(var(--cols), var(--cell));
gap: 3px;
overflow: hidden;
}
.month-label {
font-family: var(--font-ui);
font-size: 9px;
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
padding-left: 1px;
white-space: nowrap;
overflow: hidden;
}
.grid-row {
display: flex;
gap: 4px;
align-items: flex-start;
flex-shrink: 0;
}
.day-labels {
display: flex;
flex-direction: column;
gap: 3px;
flex-shrink: 0;
width: 28px;
}
.day-label {
font-family: var(--font-ui);
font-size: 8px;
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
height: var(--cell);
line-height: var(--cell);
text-align: right;
}
.cell-grid {
display: grid;
grid-template-columns: repeat(var(--cols), var(--cell));
gap: 3px;
overflow: visible;
padding: 4px;
margin: -4px;
}
.week-col {
display: flex;
flex-direction: column;
gap: 3px;
}
.cell {
width: var(--cell);
height: var(--cell);
border-radius: 3px;
border: none;
padding: 0;
cursor: pointer;
transition: filter var(--t-fast), transform var(--t-fast);
}
.cell:hover:not(.cell-future) {
filter: brightness(1.5);
transform: scale(1.2);
z-index: 1;
position: relative;
}
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
.legend {
display: flex;
align-items: center;
gap: 3px;
justify-content: flex-end;
flex-shrink: 0;
padding-top: 2px;
}
.legend-cell {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
.legend-label {
font-family: var(--font-ui);
font-size: 9px;
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.heatmap-tip {
position: fixed;
transform: translate(-50%, -100%);
background: var(--bg-overlay);
border: 1px solid var(--border-base);
border-radius: var(--radius-sm);
padding: 4px 8px;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-secondary);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
pointer-events: none;
z-index: 9999;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
</style>
+99 -37
View File
@@ -12,7 +12,8 @@
import HeroStage from "./HeroStage.svelte"; import HeroStage from "./HeroStage.svelte";
import HeroSlotPicker from "./HeroSlotPicker.svelte"; import HeroSlotPicker from "./HeroSlotPicker.svelte";
import ActivityFeed from "./ActivityFeed.svelte"; import ActivityFeed from "./ActivityFeed.svelte";
import UpdatesRow from "./UpdatesRow.svelte"; import ActivityHeatmap from "./ActivityHeatmap.svelte";
import RecsRow from "./RecsRow.svelte";
import StatsGrid from "./StatsGrid.svelte"; import StatsGrid from "./StatsGrid.svelte";
let libraryManga: Manga[] = $state([]); let libraryManga: Manga[] = $state([]);
@@ -223,44 +224,59 @@
<div class="root"> <div class="root">
<div class="body"> <div class="body">
<HeroStage <div class="hero-shrink-guard">
{resolvedSlots} <HeroStage
bind:activeIdx {resolvedSlots}
{heroThumb} bind:activeIdx
{heroTitle} {heroThumb}
{heroManga} {heroTitle}
{heroEntry} {heroManga}
{heroMangaId} {heroEntry}
{heroChapters} {heroMangaId}
{loadingHeroChapters} {heroChapters}
{resuming} {loadingHeroChapters}
onresume={resumeActive} {resuming}
onopenchapter={openChapter} onresume={resumeActive}
oncyclenext={cycleNext} onopenchapter={openChapter}
oncycleprev={cyclePrev} oncyclenext={cycleNext}
ongotoslot={goToSlot} oncycleprev={cyclePrev}
onopenpicker={openPicker} ongotoslot={goToSlot}
onunpin={unpinSlot} onopenpicker={openPicker}
onviewall={() => { if (heroManga) store.activeManga = heroManga; }} onunpin={unpinSlot}
/> onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
/>
</div>
<ActivityFeed <div class="scroll-body">
entries={recentHistory} <div class="mid-row">
onresume={resumeEntry} <div class="mid-left">
onviewhistory={() => setNavPage("history")} <ActivityFeed
onopenlibrary={() => setNavPage("library")} entries={recentHistory}
/> onresume={resumeEntry}
onviewhistory={() => setNavPage("history")}
onopenlibrary={() => setNavPage("library")}
/>
</div>
<div class="mid-divider"></div>
<div class="mid-right">
<RecsRow
{libraryManga}
history={store.history}
onopenrecommended={(m) => { store.previewManga = m; }}
/>
</div>
</div>
<div class="bottom-row"> <div class="bottom-row">
<UpdatesRow <div class="bottom-heatmap">
updates={libraryUpdates} <span class="bottom-label">Activity</span>
{libraryManga} <ActivityHeatmap dailyReadCounts={store.dailyReadCounts} />
{lastRefresh} </div>
onopen={(m) => { if (m) store.previewManga = m; }}
onclear={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}
/>
<div class="bottom-divider"></div> <div class="bottom-divider"></div>
<StatsGrid {stats} updateCount={libraryUpdates.length} /> <div class="bottom-stats">
<StatsGrid {stats} updateCount={libraryUpdates.length} />
</div>
</div>
</div> </div>
</div> </div>
@@ -288,19 +304,65 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
min-height: 0;
}
.hero-shrink-guard { flex-shrink: 0; }
.scroll-body {
flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
min-height: 0; min-height: 0;
scrollbar-width: none;
} }
.scroll-body::-webkit-scrollbar { display: none; }
.mid-row {
display: grid;
grid-template-columns: 1fr 1px 1.4fr;
border-top: 1px solid var(--border-dim);
flex-shrink: 0;
min-height: 0;
}
.mid-left {
min-width: 0;
overflow: hidden;
}
/* suppress ActivityFeed's own border-top — mid-row provides it */
.mid-left :global(.section) { border-top: none; }
.mid-divider { background: var(--border-dim); align-self: stretch; }
.mid-right {
min-width: 0;
overflow: hidden;
padding: var(--sp-3) var(--sp-4) var(--sp-4);
}
.bottom-row { .bottom-row {
display: grid; display: grid;
grid-template-columns: 1fr 1px 1fr; grid-template-columns: 1fr 1px 1fr;
padding: var(--sp-4) var(--sp-4) var(--sp-5);
border-top: 1px solid var(--border-dim); border-top: 1px solid var(--border-dim);
gap: var(--sp-4);
flex-shrink: 0; flex-shrink: 0;
} }
.bottom-divider { background: var(--border-dim); align-self: stretch; } .bottom-divider { background: var(--border-dim); align-self: stretch; }
.bottom-heatmap {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-4) var(--sp-4) var(--sp-5);
min-width: 0;
}
.bottom-stats {
padding: var(--sp-4) var(--sp-4) var(--sp-5);
min-width: 0;
overflow: hidden;
}
.bottom-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); } from { opacity: 0; transform: translateY(6px); }
+244
View File
@@ -0,0 +1,244 @@
<script lang="ts">
import { ArrowLeft, ArrowRight, Sparkle } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga } from "@types";
import type { HistoryEntry } from "@store/state.svelte";
import { fetchRecommendations, topGenres } from "../lib/recommendations";
import type { RecommendedManga } from "../lib/recommendations";
let {
libraryManga,
history,
onopenrecommended,
}: {
libraryManga: Manga[];
history: HistoryEntry[];
onopenrecommended: (m: Manga) => void;
} = $props();
const CARD_MIN_WIDTH = 100;
const GAP = 12;
const ROWS = 2;
let containerEl: HTMLDivElement | undefined = $state();
let containerWidth = $state(0);
$effect(() => {
if (!containerEl) return;
const ro = new ResizeObserver(([entry]) => {
containerWidth = entry.contentRect.width;
});
ro.observe(containerEl);
return () => ro.disconnect();
});
const cols = $derived(containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + GAP) / (CARD_MIN_WIDTH + GAP))) : 6);
const visibleCount = $derived(cols * ROWS);
const gridStyle = $derived(`grid-template-columns: repeat(${cols}, 1fr);`);
let allRecs: RecommendedManga[] = $state([]);
let loading = $state(false);
let _ctrl: AbortController | null = null;
$effect(() => {
const _history = history;
const _library = libraryManga;
if (!_history.length || !_library.length) { allRecs = []; return; }
_ctrl?.abort();
const ctrl = new AbortController();
_ctrl = ctrl;
loading = true;
fetchRecommendations(_history, _library, ctrl.signal)
.then(r => { if (!ctrl.signal.aborted) { allRecs = r; loading = false; } })
.catch(() => { if (!ctrl.signal.aborted) loading = false; });
});
const genres = $derived(topGenres(history, libraryManga));
let genreIdx = $state(0);
const activeGenre = $derived(genres[genreIdx] ?? null);
const visibleRecs = $derived(
(activeGenre
? allRecs.filter(r => r.matchedGenres.some(g => g.toLowerCase() === activeGenre.toLowerCase()))
: allRecs
).slice(0, visibleCount)
);
function prev() { genreIdx = (genreIdx - 1 + genres.length) % genres.length; }
function next() { genreIdx = (genreIdx + 1) % genres.length; }
</script>
<div class="col">
<div class="col-header">
<span class="col-title">
<Sparkle size={10} weight="bold" /> Recommended
</span>
{#if genres.length > 1}
<div class="genre-switcher">
<button class="nav-btn" onclick={prev}><ArrowLeft size={9} weight="bold" /></button>
<span class="genre-label">{activeGenre}</span>
<button class="nav-btn" onclick={next}><ArrowRight size={9} weight="bold" /></button>
</div>
{/if}
</div>
<div class="grid-container" bind:this={containerEl}>
{#if loading}
<p class="empty-msg">Loading…</p>
{:else if visibleRecs.length > 0}
<div class="card-grid" style={gridStyle}>
{#each visibleRecs as r (r.manga.id)}
<button class="card" onclick={() => onopenrecommended(r.manga)}>
<div class="card-cover-wrap">
<Thumbnail src={r.manga.thumbnailUrl} alt={r.manga.title} class="card-cover" />
<div class="card-gradient"></div>
<div class="card-footer">
<p class="card-title">{r.manga.title}</p>
<p class="card-badge">{r.matchedGenres.slice(0, 2).join(" · ")}</p>
</div>
</div>
</button>
{/each}
</div>
{:else}
<p class="empty-msg">No recommendations found</p>
{/if}
</div>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--sp-2);
flex-shrink: 0;
}
.col-title {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.genre-switcher {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.genre-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
min-width: 48px;
text-align: center;
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 2px;
color: var(--text-faint);
transition: color var(--t-base);
}
.nav-btn:hover { color: var(--accent-fg); }
.grid-container {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.card-grid {
display: grid;
grid-template-rows: repeat(2, auto);
grid-auto-rows: 0;
overflow: hidden;
gap: var(--sp-3);
align-content: start;
}
.card {
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
.card-cover-wrap {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
}
:global(.card-cover) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: filter 0.15s ease, transform 0.15s ease;
}
.card-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
pointer-events: none;
}
.card-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--sp-2);
pointer-events: none;
}
.card-title {
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: rgba(255,255,255,0.92);
line-height: var(--leading-snug);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 1px 4px rgba(0,0,0,0.7);
}
.card-badge {
font-family: var(--font-ui);
font-size: 9px;
color: rgba(255,255,255,0.45);
letter-spacing: var(--tracking-wide);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty-msg {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
padding: var(--sp-1) 0;
}
</style>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte"; import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte";
import { formatReadTime } from "../lib/homeHelpers"; import { formatReadTime } from "../lib/homeHelpers";
let { let {
stats, stats,
updateCount, updateCount,
@@ -129,4 +130,4 @@
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
white-space: nowrap; white-space: nowrap;
} }
</style> </style>
@@ -1,187 +0,0 @@
<script lang="ts">
import { Bell, ArrowRight } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga } from "@types";
import { timeAgoRefresh, handleRowWheel } from "../lib/homeHelpers";
interface LibraryUpdate {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
newChapters: number;
}
let {
updates,
libraryManga,
lastRefresh,
onopen,
onclear,
}: {
updates: LibraryUpdate[];
libraryManga: Manga[];
lastRefresh: number;
onopen: (m: Manga | undefined) => void;
onclear: () => void;
} = $props();
</script>
<div class="col">
<div class="col-header">
<span class="col-title">
<Bell size={10} weight="bold" /> Updates
{#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
</span>
{#if updates.length > 0}
<button class="action-btn" onclick={onclear}>
Clear <ArrowRight size={9} weight="bold" />
</button>
{/if}
</div>
{#if updates.length > 0}
<div class="scroll-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
{#each updates as u (u.mangaId)}
{@const m = libraryManga.find(x => x.id === u.mangaId)}
<button class="card" onclick={() => onopen(m)}>
<div class="card-cover-wrap">
<Thumbnail src={u.thumbnailUrl} alt={u.mangaTitle} class="card-cover" />
<div class="card-gradient"></div>
<div class="card-footer">
<p class="card-title">{u.mangaTitle}</p>
<p class="card-badge">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
</div>
</div>
</button>
{/each}
</div>
{:else}
<p class="empty-msg">{lastRefresh ? "No new chapters found" : "Check for updates in the library"}</p>
{/if}
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; }
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--sp-2);
}
.col-title {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.refresh-age {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
margin-left: var(--sp-2);
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--t-base);
}
.action-btn:hover { color: var(--accent-fg); }
.scroll-row {
display: flex;
flex-direction: row;
gap: var(--sp-3);
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
padding-bottom: var(--sp-1);
}
.scroll-row::-webkit-scrollbar { display: none; }
.card {
flex: 0 0 112px;
width: 112px;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
.card-cover-wrap {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
}
:global(.card-cover) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: filter 0.15s ease, transform 0.15s ease;
}
.card-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
pointer-events: none;
}
.card-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--sp-2);
pointer-events: none;
}
.card-title {
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: rgba(255,255,255,0.92);
line-height: var(--leading-snug);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 1px 4px rgba(0,0,0,0.7);
}
.card-badge {
font-family: var(--font-ui);
font-size: 9px;
color: rgba(255,255,255,0.45);
letter-spacing: var(--tracking-wide);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty-msg {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
padding: var(--sp-1) 0;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
import { gql } from "@api/client";
import { MANGAS_BY_GENRE } from "@api/queries/manga";
import { buildTagFilter } from "@features/discover/lib/searchFilter";
import type { Manga } from "@types";
import type { HistoryEntry } from "@store/state.svelte";
export interface RecommendedManga {
manga: Manga;
matchedGenres: string[];
}
const TOP_GENRES = 6;
const PAGE_SIZE = 100;
const MAX_PAGES = 5;
export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] {
const byId = new Map(libraryManga.map(m => [m.id, m]));
const tally = new Map<string, { count: number; original: string }>();
for (const entry of history) {
const manga = byId.get(entry.mangaId);
if (!manga?.genre?.length) continue;
for (const g of manga.genre) {
const key = g.toLowerCase();
const existing = tally.get(key);
if (existing) { existing.count++; }
else { tally.set(key, { count: 1, original: g }); }
}
}
return [...tally.values()]
.sort((a, b) => b.count - a.count)
.slice(0, TOP_GENRES)
.map(e => e.original);
}
type Result = { mangas: { nodes: Manga[] } };
async function fetchGenrePages(genre: string, signal?: AbortSignal): Promise<Manga[]> {
const filter = {
and: [
buildTagFilter([genre], "OR", []),
{ inLibrary: { equalTo: false } },
],
};
const pages = await Promise.all(
Array.from({ length: MAX_PAGES }, (_, i) =>
gql<Result>(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: i * PAGE_SIZE }, signal)
.then(d => d.mangas.nodes)
.catch(() => [] as Manga[])
)
);
const seen = new Set<number>();
const nodes: Manga[] = [];
for (const page of pages) {
if (!page.length) break;
for (const m of page) {
if (!seen.has(m.id)) { seen.add(m.id); nodes.push(m); }
}
if (page.length < PAGE_SIZE) break;
}
return nodes;
}
export async function fetchRecommendations(
history: HistoryEntry[],
libraryManga: Manga[],
signal?: AbortSignal,
): Promise<RecommendedManga[]> {
if (!history.length || !libraryManga.length) return [];
const genres = topGenres(history, libraryManga);
if (!genres.length) return [];
const perGenre = await Promise.all(genres.map(g => fetchGenrePages(g, signal)));
const seen = new Set<number>();
const merged: Manga[] = [];
for (const page of perGenre) {
for (const m of page) {
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
}
}
return merged.map(m => ({
manga: m,
matchedGenres: (m.genre ?? []).filter(g =>
genres.some(tg => tg.toLowerCase() === g.toLowerCase())
),
}));
}
+49 -26
View File
@@ -21,9 +21,10 @@
import type { Manga, Category, Chapter } from "@types"; import type { Manga, Category, Chapter } from "@types";
import { checkAndMarkCompleted as storeCheckAndMarkCompleted } from "@store/state.svelte"; import { checkAndMarkCompleted as storeCheckAndMarkCompleted } from "@store/state.svelte";
import LibraryToolbar from "./LibraryToolbar.svelte"; import LibraryToolbar from "./LibraryToolbar.svelte";
import LibraryGrid from "./LibraryGrid.svelte"; import LibraryGrid from "./LibraryGrid.svelte";
import LibraryFilters from "./LibraryFilters.svelte"; import LibraryFilters from "./LibraryFilters.svelte";
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte"; import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte";
@@ -48,10 +49,10 @@
let tabIndicator: { left: number; width: number } = $state({ left: 0, width: 0 }); let tabIndicator: { left: number; width: number } = $state({ left: 0, width: 0 });
let selectedIds: Set<number> = $state(new Set()); let selectedIds: Set<number> = $state(new Set());
let selectMode: boolean = $state(false); let selectMode: boolean = $state(false);
let bulkWorking: boolean = $state(false); let bulkWorking: boolean = $state(false);
let bulkMoveOpen: boolean = $state(false); let bulkAutomateOpen: boolean = $state(false);
let sortPanelOpen: boolean = $state(false); let sortPanelOpen: boolean = $state(false);
let filterPanelOpen: boolean = $state(false); let filterPanelOpen: boolean = $state(false);
@@ -66,7 +67,7 @@
let dragInsertIdx: number = $state(-1); let dragInsertIdx: number = $state(-1);
let dragTabId: number | null = $state(null); let dragTabId: number | null = $state(null);
let dragOverTabId: number | null = $state(null); let dragOverTabId: number | null = $state(null);
let dropTargetTabId: number | null = $state(null);
const DT_TAB = "application/x-moku-tab"; const DT_TAB = "application/x-moku-tab";
const anims = $derived(store.settings.qolAnimations ?? true); const anims = $derived(store.settings.qolAnimations ?? true);
@@ -105,6 +106,14 @@
items = (store.settings.libraryShowAllInSaved ?? true) items = (store.settings.libraryShowAllInSaved ?? true)
? allManga.filter(m => m.inLibrary) ? allManga.filter(m => m.inLibrary)
: (categoryMangaMap.get(0) ?? []); : (categoryMangaMap.get(0) ?? []);
if ((store.settings.libraryShowAllInSaved ?? true) && (store.settings.libraryHideCompletedInSaved ?? false)) {
const completedCat = store.categories.find(c => c.name === COMPLETED_NAME);
if (completedCat) {
const completedIds = new Set((categoryMangaMap.get(completedCat.id) ?? []).map(m => m.id));
items = items.filter(m => !completedIds.has(m.id));
}
}
} else if (tab === "downloaded") { } else if (tab === "downloaded") {
items = allManga.filter(m => (m.downloadCount ?? 0) > 0); items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
} else { } else {
@@ -166,7 +175,7 @@
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; }); if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
}); });
$effect(() => { tab; untrack(() => exitSelectMode()); }); $effect(() => { tab; untrack(() => exitSelectMode()); });
$effect(() => { tab; setTimeout(updateTabIndicator); }); $effect(() => { tab; counts; requestAnimationFrame(updateTabIndicator); });
let prevChapterId: number | null = null; let prevChapterId: number | null = null;
$effect(() => { $effect(() => {
@@ -179,13 +188,11 @@
if (!tabsEl) return; if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.active"); const active = tabsEl.querySelector<HTMLElement>(".tab.active");
if (!active) return; if (!active) return;
const parent = tabsEl.getBoundingClientRect(); tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
const rect = active.getBoundingClientRect();
tabIndicator = { left: rect.left - parent.left, width: rect.width };
} }
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); } function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
function exitSelectMode() { selectMode = false; selectedIds = new Set(); bulkMoveOpen = false; } function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); } function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); } function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); } function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
@@ -299,7 +306,7 @@
} }
async function bulkMoveToCategory(cat: Category) { async function bulkMoveToCategory(cat: Category) {
bulkWorking = true; bulkMoveOpen = false; bulkWorking = true;
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? toggleMangaCategory(m, cat) : Promise.resolve(); })); } try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? toggleMangaCategory(m, cat) : Promise.resolve(); })); }
finally { bulkWorking = false; exitSelectMode(); } finally { bulkWorking = false; exitSelectMode(); }
} }
@@ -310,6 +317,11 @@
finally { bulkWorking = false; exitSelectMode(); } finally { bulkWorking = false; exitSelectMode(); }
} }
function bulkAutomate() {
if (selectedIds.size === 0) return;
bulkAutomateOpen = true;
}
function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); } function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); }
async function openMangaFolder(m: Manga) { async function openMangaFolder(m: Manga) {
@@ -412,26 +424,30 @@
function onTabDragOver(e: DragEvent, cat: Category, idx: number) { function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return; if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return;
e.preventDefault(); e.dataTransfer!.dropEffect = "move"; e.preventDefault(); e.dataTransfer!.dropEffect = "move";
dragOverTabId = cat.id; dragInsertIdx = idx; dragOverTabId = cat.id;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
} }
function onTabDragLeave() { dragOverTabId = null; } function onTabDragLeave() { dragOverTabId = null; }
async function onTabDrop(e: DragEvent, dropCat: Category) { async function onTabDrop(e: DragEvent, dropCat: Category) {
e.preventDefault(); dragOverTabId = null; dragInsertIdx = -1; e.preventDefault(); dragOverTabId = null;
const insertAt = dragInsertIdx;
dragInsertIdx = -1;
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; } if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
const dragId = dragTabId; dragTabId = null; activeDragKind = null; const dragId = dragTabId; dragTabId = null; activeDragKind = null;
const sorted = [...store.categories].filter(c => c.id !== 0).sort((a, b) => a.order - b.order); const sorted = [...store.categories].filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
const fromIdx = sorted.findIndex(c => c.id === dragId); const fromIdx = sorted.findIndex(c => c.id === dragId);
const toIdx = sorted.findIndex(c => c.id === dropCat.id); if (fromIdx < 0) return;
if (fromIdx < 0 || toIdx < 0) return; const reordered = [...sorted];
const reordered = [...sorted]; const [moved] = reordered.splice(fromIdx, 1);
const [moved] = reordered.splice(fromIdx, 1); 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(); }
} }
@@ -459,7 +475,7 @@
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onDocMouseDown, true); document.addEventListener("mousedown", onDocMouseDown, true);
updateTabIndicator(); requestAnimationFrame(updateTabIndicator);
return () => { return () => {
ro.disconnect(); unsub(); ro.disconnect(); unsub();
@@ -509,6 +525,7 @@
</div> </div>
{:else} {:else}
<LibraryToolbar <LibraryToolbar
onclick={(e: MouseEvent) => { if (selectMode && !(e.target as HTMLElement).closest("button, input")) exitSelectMode(); }}
{tab} {tab}
{tabSortMode} {tabSortMode}
{tabSortDir} {tabSortDir}
@@ -567,6 +584,7 @@
{remainingCount} {remainingCount}
renderLimit={store.settings.renderLimit ?? 48} renderLimit={store.settings.renderLimit ?? 48}
cropCovers={store.settings.libraryCropCovers} cropCovers={store.settings.libraryCropCovers}
statsAlways={store.settings.libraryStatsAlways ?? false}
libraryFilter={tab} libraryFilter={tab}
onCardClick={onCardClick} onCardClick={onCardClick}
onCardContextMenu={openCtx} onCardContextMenu={openCtx}
@@ -577,12 +595,11 @@
onRetry={() => retryCount++} onRetry={() => retryCount++}
onExitSelectMode={exitSelectMode} onExitSelectMode={exitSelectMode}
onSelectAll={selectAll} onSelectAll={selectAll}
onBulkMove={(cat) => { bulkMoveOpen = !bulkMoveOpen; }} onBulkMove={bulkMoveToCategory}
onBulkRemove={bulkRemoveFromLibrary} onBulkRemove={bulkRemoveFromLibrary}
onBulkAutomate={bulkAutomate}
{bulkWorking} {bulkWorking}
{bulkMoveOpen}
{visibleCategories} {visibleCategories}
onCategoryMove={bulkMoveToCategory}
/> />
{/if} {/if}
</div> </div>
@@ -593,6 +610,12 @@
{#if emptyCtx} {#if emptyCtx}
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} /> <ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
{/if} {/if}
{#if bulkAutomateOpen}
<BulkAutomationPanel
ids={selectedIds}
onClose={() => { bulkAutomateOpen = false; exitSelectMode(); }}
/>
{/if}
<style> <style>
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; } .root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; }
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Folder, Trash, CheckSquare, X } from "phosphor-svelte"; import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga, Category } from "@types"; import type { Manga, Category } from "@types";
@@ -15,9 +15,9 @@
remainingCount: number; remainingCount: number;
renderLimit: number; renderLimit: number;
cropCovers: boolean; cropCovers: boolean;
statsAlways: boolean;
libraryFilter: string; libraryFilter: string;
bulkWorking: boolean; bulkWorking: boolean;
bulkMoveOpen: boolean;
visibleCategories: Category[]; visibleCategories: Category[];
onCardClick: (e: MouseEvent, m: Manga) => void; onCardClick: (e: MouseEvent, m: Manga) => void;
onCardContextMenu: (e: MouseEvent, m: Manga) => void; onCardContextMenu: (e: MouseEvent, m: Manga) => void;
@@ -30,42 +30,51 @@
onSelectAll: () => void; onSelectAll: () => void;
onBulkMove: (cat: Category) => void; onBulkMove: (cat: Category) => void;
onBulkRemove: () => void; onBulkRemove: () => void;
onCategoryMove: (cat: Category) => void; onBulkAutomate: () => void;
} }
let { let {
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds, visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
hasMore, remainingCount, renderLimit, cropCovers, libraryFilter, hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
bulkWorking, bulkMoveOpen, visibleCategories, bulkWorking, visibleCategories,
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave, onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave,
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onCategoryMove, onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
}: Props = $props(); }: Props = $props();
let bulkMoveOpen: boolean = $state(false);
$effect(() => {
if (!bulkMoveOpen) return;
function onOutside(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".bulk-move-wrap")) bulkMoveOpen = false;
}
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
return () => document.removeEventListener("mousedown", onOutside, true);
});
$effect(() => { if (!selectMode) bulkMoveOpen = false; });
</script> </script>
{#if selectMode} {#if selectMode}
<div class="select-bar"> <div class="select-bar">
<div class="select-bar-left"> <span class="sel-count">{selectedIds.size} selected</span>
<button class="sel-btn sel-cancel" onclick={onExitSelectMode} title="Cancel (Esc)"> <button class="sel-text-btn" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
<X size={13} weight="bold" />
</button>
<span class="sel-count">{selectedIds.size} selected</span>
<button class="sel-btn sel-all" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
</div>
<div class="select-bar-right"> <div class="select-bar-right">
{#if visibleCategories.length} {#if visibleCategories.length}
<div class="bulk-move-wrap"> <div class="bulk-move-wrap">
<button <button
class="sel-btn sel-move" class="sel-action-btn"
disabled={selectedIds.size === 0 || bulkWorking} disabled={selectedIds.size === 0 || bulkWorking}
onclick={() => onBulkMove(visibleCategories[0])} onclick={() => bulkMoveOpen = !bulkMoveOpen}
> >
<Folder size={13} weight="bold" /> <Folder size={13} weight="bold" />
Move to folder Move
</button> </button>
{#if bulkMoveOpen} {#if bulkMoveOpen}
<div class="bulk-folder-list"> <div class="bulk-folder-list">
{#each visibleCategories as cat} {#each visibleCategories as cat}
<button class="bulk-folder-item" onclick={() => onCategoryMove(cat)}> <button class="bulk-folder-item" onclick={() => { onBulkMove(cat); bulkMoveOpen = false; }}>
<Folder size={11} weight="bold" /> <Folder size={11} weight="bold" />
{cat.name} {cat.name}
</button> </button>
@@ -74,7 +83,11 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<button class="sel-btn sel-remove" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}> <button class="sel-action-btn" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkAutomate}>
<Robot size={13} weight="bold" />
Automate
</button>
<button class="sel-action-btn sel-action-danger" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}>
<Trash size={13} weight="bold" /> <Trash size={13} weight="bold" />
Remove Remove
</button> </button>
@@ -82,7 +95,7 @@
</div> </div>
{/if} {/if}
<div class="content"> <div class="content" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}>
{#if loading} {#if loading}
<div class="grid"> <div class="grid">
{#each Array(12) as _} {#each Array(12) as _}
@@ -116,23 +129,17 @@
> >
<div class="cover-wrap" class:completed={isCompleted}> <div class="cover-wrap" class:completed={isCompleted}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
<div class="card-info-overlay" class:anim={anims} class:instant={!anims}> <div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
{#if isCompleted} <div class="overlay-badges">
<span class="info-chip info-chip-done">✓ complete</span> {#if isCompleted}
{:else if m.unreadCount} <span class="badge badge-done">✓ Done</span>
<span class="info-chip info-chip-unread"> {:else if m.unreadCount}
<span class="info-chip-dot"></span> <span class="badge badge-unread">{m.unreadCount} new</span>
{m.unreadCount} unread {/if}
</span> {#if m.downloadCount}
{:else} <span class="badge badge-dl">{m.downloadCount}</span>
<span></span> {/if}
{/if} </div>
{#if m.downloadCount}
<span class="info-chip info-chip-dl">
<span class="info-chip-dot"></span>
{m.downloadCount}
</span>
{/if}
</div> </div>
{#if selectMode} {#if selectMode}
<div class="select-overlay" aria-hidden="true"> <div class="select-overlay" aria-hidden="true">
@@ -163,22 +170,17 @@
<style> <style>
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; } .content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; } .select-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; position: relative; z-index: 10; }
.select-bar-left { display: flex; align-items: center; gap: var(--sp-3); } .select-bar-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; position: relative; }
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); position: relative; } .sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); } .sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
.sel-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-base); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; } .sel-text-btn:hover { color: var(--text-primary); }
.sel-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); } .sel-action-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
.sel-btn:disabled { opacity: 0.4; cursor: not-allowed; } .sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.sel-cancel { border-color: transparent; background: transparent; } .sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-cancel:hover { background: var(--bg-raised); border-color: var(--border-dim); } .sel-action-danger:hover:not(:disabled) { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent); background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent); }
.sel-move { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.sel-move:hover:not(:disabled) { background: var(--accent-dim); }
.sel-remove { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 30%, transparent); }
.sel-remove:hover:not(:disabled) { background: color-mix(in srgb, var(--color-error, #e05c5c) 12%, transparent); }
.sel-all { border-color: transparent; background: transparent; }
.bulk-move-wrap { position: relative; } .bulk-move-wrap { position: relative; }
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 200; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; } .bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 9999; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
.bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); } .bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); } .bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
.grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); } .grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
@@ -193,15 +195,16 @@
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); } .card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); } .cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
.card.anims .cover { transition: filter var(--t-base); } .card.anims .cover { transition: filter var(--t-base); }
.card-info-overlay { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: flex-end; justify-content: space-between; padding: 20px 5px 5px; background: linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.3) 55%, transparent 100%); opacity: 0; transform: translateY(3px); pointer-events: none; } .card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
.card-info-overlay.anim { transition: opacity 0.18s ease, transform 0.18s cubic-bezier(0.16,1,0.3,1); } .card-info-overlay.anim { transition: opacity 0.18s ease; }
.card-info-overlay.instant { transition: none; } .card-info-overlay.instant { transition: none; }
.card:not(.select-mode):hover .card-info-overlay { opacity: 1; transform: translateY(0); } .card-info-overlay.always { opacity: 1; }
.info-chip { display: flex; align-items: center; gap: 4px; font-size: 10px; font-weight: 700; letter-spacing: 0.03em; line-height: 1; padding: 3px 6px; border-radius: 4px; background: rgba(0,0,0,0.52); backdrop-filter: blur(6px); } .card:not(.select-mode):hover .card-info-overlay { opacity: 1; }
.info-chip-unread { color: #fff; } .overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
.info-chip-done { color: var(--accent-fg); font-size: 9px; letter-spacing: 0.06em; text-transform: uppercase; } .badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
.info-chip-dl { color: var(--accent-fg); } .badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
.info-chip-dot { width: 4px; height: 4px; border-radius: 50%; background: currentColor; flex-shrink: 0; } .badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
.select-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; } .select-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); } .select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
.select-check.checked { color: var(--accent-fg); opacity: 1; } .select-check.checked { color: var(--accent-fg); opacity: 1; }
@@ -217,4 +220,4 @@
.load-more-count { color: var(--text-faint); font-size: var(--text-2xs); } .load-more-count { color: var(--text-faint); font-size: var(--text-2xs); }
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); } .center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style> </style>
@@ -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 var(--accent-dim); } .tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid transparent; }
.tabs-anims .tab.active { background: transparent; border-color: transparent; } .tabs-anims .tab.active { background: transparent; }
.tab-dragging { opacity: 0.4; cursor: grabbing; } .tab-dragging { opacity: 0.4; cursor: grabbing; }
.tab-drop-target { background: var(--accent-muted) !important; color: var(--accent-fg) !important; outline: 1px dashed var(--accent); }
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; } .tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
.tab-count { font-size: var(--text-2xs); opacity: 0.6; } .tab-count { font-size: var(--text-2xs); opacity: 0.6; }
.search-wrap { position: relative; display: flex; align-items: center; } .search-wrap { position: relative; display: flex; align-items: center; }
+11 -5
View File
@@ -4,8 +4,8 @@ import { UPDATE_LIBRARY } from "@api/mutations/manga";
import { GET_RECENTLY_UPDATED } from "@api/queries/chapters"; import { GET_RECENTLY_UPDATED } from "@api/queries/chapters";
import type { LibraryUpdateEntry } from "@store/state.svelte"; import type { LibraryUpdateEntry } from "@store/state.svelte";
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
const POLL_INITIAL_MS = 2000; const POLL_INITIAL_MS = 2000;
export interface UpdateProgress { export interface UpdateProgress {
finished: number; finished: number;
@@ -64,7 +64,13 @@ export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => vo
if (!jobsInfo.isRunning && seenWork) { if (!jobsInfo.isRunning && seenWork) {
const recent = await gql<{ const recent = await gql<{
chapters: { nodes: { mangaId: number; mangaTitle: string; thumbnailUrl: string; fetchedAt: string }[] } chapters: {
nodes: {
mangaId: number;
fetchedAt: string;
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean };
}[]
}
}>(GET_RECENTLY_UPDATED, {}).catch(() => ({ chapters: { nodes: [] } })); }>(GET_RECENTLY_UPDATED, {}).catch(() => ({ chapters: { nodes: [] } }));
if (cancelled) return; if (cancelled) return;
@@ -79,8 +85,8 @@ export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => vo
} else { } else {
byManga.set(ch.mangaId, { byManga.set(ch.mangaId, {
mangaId: ch.mangaId, mangaId: ch.mangaId,
mangaTitle: ch.mangaTitle, mangaTitle: ch.manga.title,
thumbnailUrl: ch.thumbnailUrl, thumbnailUrl: ch.manga.thumbnailUrl,
newChapters: 1, newChapters: 1,
checkedAt: Date.now(), checkedAt: Date.now(),
}); });
@@ -0,0 +1,272 @@
<script lang="ts">
import { X } from "phosphor-svelte";
import { setPref } from "@features/series/lib/mangaPrefs";
import { store } from "@store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import type { MangaPrefs } from "@store/state.svelte";
let { ids, onClose }: {
ids: Set<number>;
onClose: () => void;
} = $props();
const DOWNLOAD_AHEAD_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 2, label: "2" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
];
const MAX_KEEP_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
{ value: 25, label: "25" },
];
const DELETE_DELAY_OPTIONS = [
{ value: 0, label: "Now" },
{ value: 24, label: "1 day" },
{ value: 168, label: "1 week" },
];
const REFRESH_INTERVAL_OPTIONS = [
{ value: "global", label: "Default" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "manual", label: "Manual" },
];
let draft: MangaPrefs = $state({ ...DEFAULT_MANGA_PREFS });
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
function apply() {
for (const id of ids) {
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
setPref(id, key, draft[key] as any);
}
}
onClose();
}
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
<div class="modal-header">
<div class="header-left">
<span class="modal-title">Automation</span>
<span class="modal-subtitle">{ids.size} series selected</span>
</div>
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
</div>
<div class="modal-body">
<p class="section-label">Downloads</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Auto-download new chapters</span>
<span class="auto-desc">Queue new chapters when this series refreshes</span>
</div>
<button
role="switch"
aria-checked={get("autoDownload")}
aria-label="Auto-download new chapters"
class="auto-toggle"
class:auto-toggle-on={get("autoDownload")}
onclick={() => set("autoDownload", !get("autoDownload"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Download ahead</span>
<span class="auto-desc">Pre-fetch chapters while reading</span>
</div>
<div class="auto-chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("downloadAhead") === opt.value}
onclick={() => set("downloadAhead", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Max chapters to keep</span>
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
</div>
<div class="auto-chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("maxKeepChapters") === opt.value}
onclick={() => set("maxKeepChapters", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="divider"></div>
<p class="section-label">On Read</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Delete after reading</span>
<span class="auto-desc">Remove download when chapter is marked read</span>
</div>
<button
role="switch"
aria-checked={get("deleteOnRead")}
aria-label="Delete after reading"
class="auto-toggle"
class:auto-toggle-on={get("deleteOnRead")}
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
><span class="auto-toggle-thumb"></span></button>
</div>
{#if get("deleteOnRead")}
<div class="auto-row auto-row-sub">
<span class="auto-label">Delete delay</span>
<div class="auto-chip-group">
{#each DELETE_DELAY_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("deleteDelayHours") === opt.value}
onclick={() => set("deleteDelayHours", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<div class="divider"></div>
<p class="section-label">Updates</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Pause updates</span>
<span class="auto-desc">Skip this series during global refresh</span>
</div>
<button
role="switch"
aria-checked={get("pauseUpdates")}
aria-label="Pause updates"
class="auto-toggle"
class:auto-toggle-on={get("pauseUpdates")}
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Refresh interval</span>
<span class="auto-desc">How often to check for new chapters</span>
</div>
<div class="auto-chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("refreshInterval") === opt.value}
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
>{opt.label}</button>
{/each}
</div>
</div>
</div>
<div class="modal-footer">
<button class="apply-btn" onclick={apply}>Apply to {ids.size} series</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 300;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
}
.modal {
width: 420px; max-width: calc(100vw - var(--sp-6));
max-height: 80vh;
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.header-left { display: flex; flex-direction: column; gap: 2px; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.modal-body {
flex: 1; overflow-y: auto; scrollbar-width: none;
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
}
.modal-body::-webkit-scrollbar { display: none; }
.modal-footer {
padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border-dim); flex-shrink: 0;
}
.apply-btn {
width: 100%; padding: 8px; border-radius: var(--radius-md);
border: 1px solid var(--accent-dim); background: var(--accent-muted);
color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); cursor: pointer;
transition: background var(--t-base), border-color var(--t-base);
}
.apply-btn:hover { background: var(--accent-dim); border-color: var(--accent); }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-widest); color: var(--text-faint);
text-transform: uppercase; margin: 0;
}
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
+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; }
+151 -25
View File
@@ -5,7 +5,8 @@
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads"; import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
import { store, updateSettings, openReader, closeReader, addHistory, import { store, updateSettings, openReader, closeReader, addHistory,
addBookmark, removeBookmark, addMarker, updateMarker, removeMarker, addBookmark, removeBookmark, addMarker, updateMarker, removeMarker,
setSettingsOpen } from "@store/state.svelte"; setSettingsOpen, setMangaReaderSettings, clearMangaReaderSettings,
saveReaderPreset, updateReaderPreset, deleteReaderPreset } from "@store/state.svelte";
import { setReading } from "@store/discord"; import { setReading } from "@store/discord";
import { DEFAULT_KEYBINDS } from "@core/keybinds/defaultBinds"; import { DEFAULT_KEYBINDS } from "@core/keybinds/defaultBinds";
import { readerState, PAGE_STYLES } from "../store/readerState.svelte"; import { readerState, PAGE_STYLES } from "../store/readerState.svelte";
@@ -21,20 +22,31 @@
import PageView from "./PageView.svelte"; import PageView from "./PageView.svelte";
import ReaderProgressBar from "./ReaderProgressBar.svelte"; import ReaderProgressBar from "./ReaderProgressBar.svelte";
import ReaderOverlay from "./ReaderOverlay.svelte"; import ReaderOverlay from "./ReaderOverlay.svelte";
import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
const win = getCurrentWindow(); const win = getCurrentWindow();
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH"); const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
const rtl = $derived(store.settings.readingDirection === "rtl");
const fit = $derived((store.settings.fitMode ?? "width") as FitMode); const effectiveReaderSettings = $derived.by(() => {
const style = $derived((store.settings.pageStyle ?? "single") as typeof PAGE_STYLES[number]); const mangaId = store.activeManga?.id;
const zoom = $derived(store.settings.readerZoom ?? 1.0); const override = mangaId != null ? (store.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
return override ? { ...store.settings, ...override } : store.settings;
});
const rtl = $derived(effectiveReaderSettings.readingDirection === "rtl");
const fit = $derived((effectiveReaderSettings.fitMode ?? "width") as FitMode);
const style = $derived((effectiveReaderSettings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
const zoom = $derived(effectiveReaderSettings.readerZoom ?? 1.0);
const autoNext = $derived(store.settings.autoNextChapter ?? false); const autoNext = $derived(store.settings.autoNextChapter ?? false);
const markOnNext = $derived(store.settings.markReadOnNext ?? true); const markOnNext = $derived(store.settings.markReadOnNext ?? true);
const overlayBars = $derived(store.settings.overlayBars ?? false); const overlayBars = $derived(store.settings.overlayBars ?? false);
const tapToToggleBar = $derived(store.settings.tapToToggleBar ?? false); const tapToToggleBar = $derived(store.settings.tapToToggleBar ?? false);
const barPosition = $derived((store.settings.barPosition ?? "top") as "top" | "left" | "right");
const isVerticalBar = $derived(barPosition === "left" || barPosition === "right");
const lastPage = $derived(store.pageUrls.length); const lastPage = $derived(store.pageUrls.length);
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined); const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
const zoomPct = $derived(Math.round(zoom * 100)); const zoomPct = $derived(Math.round(zoom * 100));
const pinchZoomEnabled = $derived(store.settings.pinchZoom ?? false);
const displayChapter = $derived( const displayChapter = $derived(
style === "longstrip" && readerState.visibleChapterId style === "longstrip" && readerState.visibleChapterId
@@ -84,7 +96,7 @@
fit === "height" && "fit-height", fit === "height" && "fit-height",
fit === "screen" && "fit-screen", fit === "screen" && "fit-screen",
fit === "original" && "fit-original", fit === "original" && "fit-original",
store.settings.optimizeContrast && "optimize-contrast", effectiveReaderSettings.optimizeContrast && "optimize-contrast",
].filter(Boolean).join(" ")); ].filter(Boolean).join(" "));
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]); const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
@@ -119,6 +131,11 @@
const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0); const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw); const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw);
const perMangaEnabled = $derived(
store.activeManga?.id != null &&
!!(store.settings.mangaReaderSettings ?? {})[store.activeManga.id]
);
let containerEl: HTMLDivElement | null = null; let containerEl: HTMLDivElement | null = null;
let pageViewRef: PageView; let pageViewRef: PageView;
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 }; let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
@@ -184,7 +201,7 @@
e.preventDefault(); e.preventDefault();
captureZoomAnchor(containerEl, style, zoomAnchor); captureZoomAnchor(containerEl, style, zoomAnchor);
const ZOOM_STEP = 0.05; const ZOOM_STEP = 0.05;
updateSettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)) }); applySettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)) });
restoreZoomAnchor(containerEl, zoomAnchor); restoreZoomAnchor(containerEl, zoomAnchor);
} }
@@ -202,10 +219,10 @@
closeReader, closeReader,
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl), goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
lastPage: () => lastPage, lastPage: () => lastPage,
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); }, adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); }, resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); }, cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
toggleDirection: () => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }), toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
openSettings: () => setSettingsOpen(true), openSettings: () => setSettingsOpen(true),
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber), toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
toggleMarker: () => { toggleMarker: () => {
@@ -230,6 +247,54 @@
function bindContainer(el: HTMLDivElement) { containerEl = el; } function bindContainer(el: HTMLDivElement) { containerEl = el; }
function captureCurrentReaderSettings() {
return {
pageStyle: style,
fitMode: fit,
readingDirection: (store.settings.readingDirection ?? "ltr") as import("@store/state.svelte").ReadingDirection,
readerZoom: zoom,
pageGap: effectiveReaderSettings.pageGap ?? true,
optimizeContrast: effectiveReaderSettings.optimizeContrast ?? false,
offsetDoubleSpreads: effectiveReaderSettings.offsetDoubleSpreads ?? false,
} satisfies import("@store/state.svelte").ReaderSettings;
}
function applySettings(patch: Parameters<typeof updateSettings>[0]) {
const mangaId = store.activeManga?.id;
if (mangaId != null && (store.settings.mangaReaderSettings ?? {})[mangaId]) {
setMangaReaderSettings(mangaId, { ...(store.settings.mangaReaderSettings ?? {})[mangaId]!, ...patch });
} else {
updateSettings(patch);
}
}
function handleTogglePerManga() {
const mangaId = store.activeManga?.id;
if (mangaId == null) return;
if ((store.settings.mangaReaderSettings ?? {})[mangaId]) {
clearMangaReaderSettings(mangaId);
} else {
setMangaReaderSettings(mangaId, captureCurrentReaderSettings());
}
}
function handleSavePreset(name: string) {
saveReaderPreset(name, captureCurrentReaderSettings());
}
function handleApplyPreset(settings: import("@store/state.svelte").ReaderSettings) {
const mangaId = store.activeManga?.id;
if (mangaId != null && (store.settings.mangaReaderSettings ?? {})[mangaId]) {
setMangaReaderSettings(mangaId, settings);
} else {
updateSettings(settings);
}
}
function handleBarPositionChange(pos: "top" | "left" | "right") {
updateSettings({ barPosition: pos });
}
$effect(() => { $effect(() => {
const chapter = displayChapter; const chapter = displayChapter;
const manga = store.activeManga; const manga = store.activeManga;
@@ -346,7 +411,7 @@
const snap = store.pageUrls; const snap = store.pageUrls;
Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => { Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => {
if (cancelled || snap !== store.pageUrls) return; if (cancelled || snap !== store.pageUrls) return;
readerState.pageGroups = buildPageGroups(snap, aspects, store.settings.offsetDoubleSpreads ?? false); readerState.pageGroups = buildPageGroups(snap, aspects, effectiveReaderSettings.offsetDoubleSpreads ?? false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
} else { readerState.pageGroups = []; } } else { readerState.pageGroups = []; }
@@ -415,6 +480,8 @@
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
window.addEventListener("mousemove", pageViewRef.onInspectMouseMove); window.addEventListener("mousemove", pageViewRef.onInspectMouseMove);
window.addEventListener("mouseup", pageViewRef.onInspectMouseUp); window.addEventListener("mouseup", pageViewRef.onInspectMouseUp);
window.addEventListener("pointermove", pageViewRef.onPointerMove);
window.addEventListener("pointerup", pageViewRef.onPointerUp);
readerState.isFullscreen = await win.isFullscreen(); readerState.isFullscreen = await win.isFullscreen();
const unlistenFs = await win.onResized(async () => { const unlistenFs = await win.onResized(async () => {
@@ -436,6 +503,8 @@
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove); window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove);
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp); window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
cleanupScroll(); cleanupScroll();
unlistenFs(); unlistenFs();
ro.disconnect(); ro.disconnect();
@@ -446,17 +515,27 @@
<div <div
class="root" class="root"
class:overlay-bars={overlayBars} class:overlay-bars={overlayBars}
class:bar-left={barPosition === "left"}
class:bar-right={barPosition === "right"}
class:pinch-active={pinchZoomEnabled}
role="presentation" role="presentation"
onmousemove={(e) => { if (!tapToToggleBar && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi(); }} onmousemove={(e) => {
if (!tapToToggleBar) {
if (barPosition === "top" && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi();
if (barPosition === "left" && e.clientX < 60) showUi();
if (barPosition === "right" && window.innerWidth - e.clientX < 60) showUi();
}
}}
> >
<ReaderControls <ReaderControls
{displayChapter} {adjacent} {visibleChunkLastPage} {displayChapter} {adjacent} {visibleChunkLastPage}
{fit} {fitLabel} {style} {rtl} {zoom} {zoomPct} {zoom} {zoomPct}
isFullscreen={readerState.isFullscreen} isFullscreen={readerState.isFullscreen}
{isBookmarked} {hasMarkerOnPage} {currentPageMarkers} {isBookmarked} {hasMarkerOnPage} {currentPageMarkers}
{autoNext} {markOnNext}
uiVisible={readerState.uiVisible} uiVisible={readerState.uiVisible}
{hideTimer} {hideTimer}
{barPosition}
progressBar={isVerticalBar ? progressBarSnippet : undefined}
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)} onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)} onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
onMaybeMarkRead={maybeMarkCurrentRead} onMaybeMarkRead={maybeMarkCurrentRead}
@@ -464,10 +543,31 @@
onCommitMarker={commitMarker} onCommitMarker={commitMarker}
onDeleteMarker={deleteCurrentMarker} onDeleteMarker={deleteCurrentMarker}
onClampZoom={clampZoom} onClampZoom={clampZoom}
onApplySettings={applySettings}
onDlOpen={() => readerState.dlOpen = true} onDlOpen={() => readerState.dlOpen = true}
onSettingsOpen={() => setSettingsOpen(true)}
{perMangaEnabled}
{win} {win}
/> />
{#if readerState.presetOpen}
<ReaderPresetPanel
{fit} {style} {rtl} {zoom} {zoomPct}
{perMangaEnabled}
{barPosition}
onBarPositionChange={handleBarPositionChange}
onTogglePerManga={handleTogglePerManga}
onApplySettings={applySettings}
onSavePreset={handleSavePreset}
onApplyPreset={handleApplyPreset}
onUpdatePreset={updateReaderPreset}
onDeletePreset={deleteReaderPreset}
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
onClampZoom={clampZoom}
/>
{/if}
<ReaderOverlay <ReaderOverlay
{showResumeBanner} {showResumeBanner}
resumePage={readerState.resumePage} resumePage={readerState.resumePage}
@@ -486,6 +586,9 @@
{currentGroup} {stripToRender} {currentGroup} {stripToRender}
fadingOut={readerState.fadingOut} fadingOut={readerState.fadingOut}
{tapToToggleBar} {tapToToggleBar}
{pinchZoomEnabled}
onGetZoom={() => zoom}
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)} resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
onTap={handleTap} onTap={handleTap}
onWheel={handleWheel} onWheel={handleWheel}
@@ -493,21 +596,44 @@
{bindContainer} {bindContainer}
/> />
<ReaderProgressBar {#snippet progressBarSnippet()}
{style} <ReaderProgressBar
loading={readerState.loading} {style}
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage} loading={readerState.loading}
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent} {rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
uiVisible={readerState.uiVisible} {displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
onGoPrev={goPrev} uiVisible={readerState.uiVisible}
onGoNext={goNext} {barPosition}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)} onGoPrev={goPrev}
/> onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
/>
{/snippet}
{#if !isVerticalBar}
<ReaderProgressBar
{style}
loading={readerState.loading}
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
uiVisible={readerState.uiVisible}
{barPosition}
onGoPrev={goPrev}
onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
/>
{/if}
</div> </div>
<style> <style>
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; } .root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
.root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; } .root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
.root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; } .root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
.root.overlay-bars :global(.viewer) { height: 100%; } .root.overlay-bars :global(.viewer) { height: 100%; }
.root.bar-left :global(.viewer) { margin-left: 40px; }
.root.bar-right :global(.viewer) { margin-right: 40px; }
.root.pinch-active :global(.viewer) { touch-action: none; }
</style> </style>
@@ -1,83 +1,95 @@
<script lang="ts"> <script lang="ts">
import { import {
X, CaretLeft, CaretRight, X, CaretLeft, CaretRight, CaretUp, CaretDown,
Square, Rows, BookOpen, MonitorPlay,
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
MagnifyingGlassMinus, MagnifyingGlassPlus, MagnifyingGlassMinus, MagnifyingGlassPlus,
Bookmark, MapPin, Download, Check, Bookmark, MapPin, Download, Check, GearSix, Sliders,
} from "phosphor-svelte"; } from "phosphor-svelte";
import { store, updateSettings } from "@store/state.svelte"; import { store, updateSettings } from "@store/state.svelte";
import { openReader, closeReader } from "@store/state.svelte"; import { openReader, closeReader } from "@store/state.svelte";
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX, PAGE_STYLES } from "../store/readerState.svelte"; import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
import type { FitMode } from "@store/state.svelte"; import { fly } from "svelte/transition";
import type { Chapter } from "@types"; import { cubicOut, cubicIn } from "svelte/easing";
import type { Chapter } from "@types";
import type { Snippet } from "svelte";
interface Props { interface Props {
displayChapter: Chapter | null; displayChapter: Chapter | null;
adjacent: { prev: Chapter | null; next: Chapter | null }; adjacent: { prev: Chapter | null; next: Chapter | null };
visibleChunkLastPage: number; visibleChunkLastPage: number;
fit: FitMode; zoom: number;
fitLabel: string; zoomPct: number;
style: string; isFullscreen: boolean;
rtl: boolean; isBookmarked: boolean;
zoom: number; hasMarkerOnPage: boolean;
zoomPct: number; currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[];
isFullscreen: boolean; uiVisible: boolean;
isBookmarked: boolean; hideTimer: ReturnType<typeof setTimeout> | null;
hasMarkerOnPage: boolean; barPosition: "top" | "left" | "right";
currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[]; progressBar?: Snippet;
autoNext: boolean;
markOnNext: boolean;
uiVisible: boolean;
hideTimer: ReturnType<typeof setTimeout> | null;
onCaptureZoomAnchor: () => void; onCaptureZoomAnchor: () => void;
onRestoreZoomAnchor: () => void; onRestoreZoomAnchor: () => void;
onMaybeMarkRead: () => void; onMaybeMarkRead: () => void;
onToggleBookmark: () => void; onToggleBookmark: () => void;
onCommitMarker: () => void; onCommitMarker: () => void;
onDeleteMarker: () => void; onDeleteMarker: () => void;
onClampZoom: (z: number) => number; onClampZoom: (z: number) => number;
onDlOpen: () => void; onApplySettings: (patch: Parameters<typeof updateSettings>[0]) => void;
win: import("@tauri-apps/api/window").Window; onDlOpen: () => void;
onSettingsOpen: () => void;
hasMangaOverride: boolean;
win: import("@tauri-apps/api/window").Window;
} }
const { const {
displayChapter, adjacent, visibleChunkLastPage, displayChapter, adjacent, visibleChunkLastPage,
fit, fitLabel, style, rtl, zoom, zoomPct, zoom, zoomPct, isFullscreen,
isFullscreen, isBookmarked, hasMarkerOnPage, currentPageMarkers, isBookmarked, hasMarkerOnPage, currentPageMarkers,
autoNext, markOnNext, uiVisible, hideTimer, uiVisible, hideTimer,
barPosition, progressBar,
onCaptureZoomAnchor, onRestoreZoomAnchor, onCaptureZoomAnchor, onRestoreZoomAnchor,
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker, onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
onClampZoom, onDlOpen, win, onClampZoom, onApplySettings, onDlOpen, onSettingsOpen,
hasMangaOverride, win,
}: Props = $props(); }: Props = $props();
const isVertical = $derived(barPosition === "left" || barPosition === "right");
const popoverSide = $derived(
barPosition === "left" ? "right" :
barPosition === "right" ? "left" :
"bottom"
);
function adjustZoom(delta: number) { function adjustZoom(delta: number) {
onCaptureZoomAnchor(); onCaptureZoomAnchor();
updateSettings({ readerZoom: onClampZoom(zoom + delta) }); onApplySettings({ readerZoom: onClampZoom(zoom + delta) });
onRestoreZoomAnchor(); onRestoreZoomAnchor();
} }
function resetZoom() { function resetZoom() {
onCaptureZoomAnchor(); onCaptureZoomAnchor();
updateSettings({ readerZoom: 1.0 }); onApplySettings({ readerZoom: 1.0 });
onRestoreZoomAnchor(); onRestoreZoomAnchor();
} }
function cycleStyle() {
const idx = PAGE_STYLES.indexOf(style as typeof PAGE_STYLES[number]);
updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] });
}
function cycleFit() {
const opts: FitMode[] = ["width", "height", "screen", "original"];
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
}
function keepUiAlive() { function keepUiAlive() {
readerState.uiVisible = true; readerState.uiVisible = true;
if (hideTimer) clearTimeout(hideTimer); if (hideTimer) clearTimeout(hideTimer);
} }
let wcTimer: ReturnType<typeof setTimeout> | null = null;
function wcResetTimer() {
if (wcTimer) clearTimeout(wcTimer);
wcTimer = setTimeout(() => { readerState.winOpen = false; }, 1500);
}
$effect(() => {
if (readerState.winOpen) wcResetTimer();
else if (wcTimer) { clearTimeout(wcTimer); wcTimer = null; }
return () => { if (wcTimer) clearTimeout(wcTimer); };
});
function openMarkerPopover() { function openMarkerPopover() {
if (currentPageMarkers.length > 0) { if (currentPageMarkers.length > 0) {
const first = currentPageMarkers[0]; const first = currentPageMarkers[0];
@@ -86,105 +98,115 @@
readerState.openMarker("", "", "yellow"); readerState.openMarker("", "", "yellow");
} }
} }
let chapterHover = $state(false);
let chapterHoverTimer: ReturnType<typeof setTimeout> | null = null;
function showChapterPopover() {
if (chapterHoverTimer) clearTimeout(chapterHoverTimer);
chapterHover = true;
}
function hideChapterPopover() {
chapterHoverTimer = setTimeout(() => { chapterHover = false; }, 120);
}
</script> </script>
<div class="topbar" class:hidden={!uiVisible}> <div
class="bar"
<div class="topbar-left"> class:bar-top={barPosition === "top"}
class:bar-left={barPosition === "left"}
class:bar-right={barPosition === "right"}
class:hidden={!uiVisible}
>
<div class="bar-start">
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button> <button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
<button class="icon-btn" <button class="icon-btn"
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); openReader(adjacent.prev, store.activeChapterList); } }} onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); openReader(adjacent.prev, store.activeChapterList); } }}
disabled={!adjacent.prev}> disabled={!adjacent.prev}>
<CaretLeft size={14} weight="light" /> {#if isVertical}
<CaretUp size={14} weight="light" />
{:else}
<CaretLeft size={14} weight="light" />
{/if}
</button> </button>
<span class="ch-label">
<span class="ch-title">{store.activeManga?.title}</span> <div
<span class="ch-sep">/</span> class="ch-hover-wrap"
<span>{displayChapter?.name}</span> onmouseenter={showChapterPopover}
</span> onmouseleave={hideChapterPopover}
role="presentation"
>
<button class="ch-pill" title="{store.activeManga?.title} / {displayChapter?.name}">
{#if isVertical}
<span class="ch-info">&#xE2CE;</span>
{:else}
<span class="ch-title">{store.activeManga?.title}</span>
<span class="ch-sep">/</span>
<span class="ch-name">{displayChapter?.name}</span>
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
{/if}
</button>
{#if chapterHover && isVertical}
<div class="ch-popover ch-popover-{popoverSide}">
<span class="ch-pop-title">{store.activeManga?.title}</span>
<span class="ch-pop-sep">/</span>
<span class="ch-pop-name">{displayChapter?.name}</span>
<span class="ch-pop-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
</div>
{/if}
</div>
<button class="icon-btn" <button class="icon-btn"
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } }} onclick={() => { if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } }}
disabled={!adjacent.next}> disabled={!adjacent.next}>
<CaretRight size={14} weight="light" /> {#if isVertical}
<CaretDown size={14} weight="light" />
{:else}
<CaretRight size={14} weight="light" />
{/if}
</button> </button>
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
{#if !isVertical}
<span class="bar-sep"></span>
{/if}
</div> </div>
<div class="topbar-right"> {#if isVertical && progressBar}
<div class="top-sep"></div> <div class="bar-middle">
{@render progressBar()}
<button class="mode-btn" onclick={cycleFit}> </div>
{#if fit === "width"}<ArrowsLeftRight size={14} weight="light" /> {/if}
{:else if fit === "height"}<ArrowsVertical size={14} weight="light" />
{:else if fit === "screen"}<ArrowsIn size={14} weight="light" />
{:else}<ArrowsOut size={14} weight="light" />{/if}
<span class="mode-label">{fitLabel}</span>
</button>
<div class="bar-end">
<div class="zoom-wrap"> <div class="zoom-wrap">
<div class="zoom-inline"> <div class="zoom-inline">
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}> <button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
<MagnifyingGlassMinus size={13} weight="light" /> <MagnifyingGlassMinus size={13} weight="light" />
</button> </button>
<div class="zoom-divider"></div>
<button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom"> <button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom">
{zoomPct}% {zoomPct}%
</button> </button>
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}> <div class="zoom-divider"></div>
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
<MagnifyingGlassPlus size={13} weight="light" /> <MagnifyingGlassPlus size={13} weight="light" />
</button> </button>
</div> </div>
{#if readerState.zoomOpen} {#if readerState.zoomOpen}
<div class="zoom-popover"> <div class="popover zoom-popover popover-{popoverSide}">
<div class="zoom-slider-row"> <div class="zoom-slider-row">
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct} <input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
oninput={(e) => { onCaptureZoomAnchor(); updateSettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} /> oninput={(e) => { onCaptureZoomAnchor(); onApplySettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
</div> </div>
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button> <button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
</div> </div>
{/if} {/if}
</div> </div>
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
</button>
<button class="mode-btn" onclick={cycleStyle} title="Cycle view mode">
{#if style === "single"}<Square size={14} weight="light" />
{:else if style === "fade"}<MonitorPlay size={14} weight="light" />
{:else if style === "double"}<BookOpen size={14} weight="light" />
{:else}<Rows size={14} weight="light" />{/if}
<span class="mode-label">{style}</span>
</button>
<div class="mode-extras">
{#if style === "double"}
<button class="mode-btn" class:active={store.settings.offsetDoubleSpreads}
onclick={() => updateSettings({ offsetDoubleSpreads: !store.settings.offsetDoubleSpreads })}>
<span class="mode-label">Offset</span>
</button>
{/if}
{#if style === "longstrip"}
<button class="mode-btn" class:active={store.settings.pageGap}
onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
<span class="mode-label">Gap</span>
</button>
<button class="mode-btn" class:active={autoNext}
onclick={() => updateSettings({ autoNextChapter: !autoNext })}>
<span class="mode-label">Auto</span>
</button>
{/if}
{#if !autoNext}
<button class="mode-btn" class:active={markOnNext}
onclick={() => updateSettings({ markReadOnNext: !markOnNext })}>
<span class="mode-label">Mk.Read</span>
</button>
{/if}
</div>
<button class="mode-btn" onclick={onDlOpen}>
<Download size={14} weight="light" />
</button>
<div class="marker-wrap"> <div class="marker-wrap">
<button <button
class="icon-btn" class="icon-btn"
@@ -198,7 +220,7 @@
</button> </button>
{#if readerState.markerOpen} {#if readerState.markerOpen}
<div class="marker-popover" role="presentation" <div class="popover marker-popover popover-{popoverSide}" role="presentation"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onmouseenter={keepUiAlive} onmouseenter={keepUiAlive}
> >
@@ -254,6 +276,20 @@
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} /> <Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
</button> </button>
<button class="icon-btn" onclick={onDlOpen}>
<Download size={14} weight="light" />
</button>
<button class="icon-btn" class:active={hasMangaOverride}
onclick={() => { readerState.presetOpen = true; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
title="Reader settings">
<Sliders size={13} weight="regular" />
</button>
<button class="icon-btn" onclick={onSettingsOpen} title="Settings">
<GearSix size={13} weight="regular" />
</button>
<div class="wc-wrap"> <div class="wc-wrap">
<button <button
class="icon-btn" class="icon-btn"
@@ -268,45 +304,98 @@
</svg> </svg>
</button> </button>
{#if readerState.winOpen} {#if readerState.winOpen}
<div class="wc-dropdown" role="presentation" onclick={(e) => e.stopPropagation()}> <div
<button class="wc-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }}> class="wc-clip wc-clip-{popoverSide}"
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5"/></svg> onmouseenter={wcResetTimer}
<span>Minimize</span> onmousemove={wcResetTimer}
</button> >
<button class="wc-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }}> <div
{#if isFullscreen} class="wc-bar"
role="presentation"
onclick={(e) => e.stopPropagation()}
in:fly={isVertical
? (barPosition === "left" ? { x: '-100%', duration: 200, easing: cubicOut } : { x: '100%', duration: 200, easing: cubicOut })
: { y: '-100%', duration: 200, easing: cubicOut }}
out:fly={isVertical
? (barPosition === "left" ? { x: '-100%', duration: 150, easing: cubicIn } : { x: '100%', duration: 150, easing: cubicIn })
: { y: '-100%', duration: 150, easing: cubicIn }}
>
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }} title="Minimize">
<svg width="10" height="2" viewBox="0 0 10 2"><line x1="0" y1="1" x2="10" y2="1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }} title={isFullscreen ? "Exit Fullscreen" : "Fullscreen"}>
{#if isFullscreen}
<svg width="11" height="11" viewBox="0 0 11 11">
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="7,1 10,1 10,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="10,7 10,10 7,10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="4,10 1,10 1,7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else}
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.75" y="0.75" width="8.5" height="8.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
{/if}
</button>
<button class="wc-icon-btn wc-icon-close" onclick={() => { readerState.winOpen = false; win.close(); }} title="Close">
<svg width="10" height="10" viewBox="0 0 10 10"> <svg width="10" height="10" viewBox="0 0 10 10">
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
{:else} </button>
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg> </div>
{/if}
<span>{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}</span>
</button>
<button class="wc-btn wc-close" onclick={() => { readerState.winOpen = false; win.close(); }}>
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>Close</span>
</button>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-1); padding: 0 var(--sp-3); height: 40px; background: var(--bg-void); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; position: relative; z-index: 2; transition: opacity 0.25s ease; } .bar {
.topbar.hidden { opacity: 0; pointer-events: none; } display: flex;
align-items: center;
gap: var(--sp-1);
background: var(--bg-void);
flex-shrink: 0;
position: relative;
z-index: 2;
transition: opacity 0.25s ease;
overflow: visible;
}
.bar.hidden { opacity: 0; pointer-events: none; }
.topbar-left { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; flex: 1; overflow: hidden; } .bar-top {
.topbar-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; } flex-direction: row;
.mode-extras { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; } justify-content: space-between;
padding: 0 var(--sp-3);
height: 40px;
border-bottom: 1px solid var(--border-dim);
}
.bar-left, .bar-right {
flex-direction: column;
justify-content: space-between;
padding: var(--sp-3) 0;
width: 40px;
position: fixed;
top: 0;
bottom: 0;
z-index: 2;
border-bottom: none;
}
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
.bar-start, .bar-end {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.bar-top .bar-start { flex: 1; overflow: hidden; }
.bar-left .bar-start,
.bar-left .bar-end,
.bar-right .bar-start,
.bar-right .bar-end {
flex-direction: column;
}
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); } .icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } .icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
@@ -314,25 +403,104 @@
.icon-btn.active { color: var(--accent-fg); } .icon-btn.active { color: var(--accent-fg); }
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; } .marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
.ch-label { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .ch-hover-wrap { position: relative; min-width: 0; }
.ch-pill {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-sm);
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
padding: 2px 4px;
border-radius: var(--radius-sm);
background: none;
cursor: default;
transition: background var(--t-fast);
}
.bar-left .ch-pill, .bar-right .ch-pill {
width: 28px;
height: 28px;
justify-content: center;
padding: 0;
}
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ch-sep { color: var(--text-faint); flex-shrink: 0; } .ch-sep { color: var(--text-faint); flex-shrink: 0; }
.page-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; } .ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); } .ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); transition: color var(--t-base), background var(--t-base); } .ch-popover {
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); } position: absolute;
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); } background: var(--bg-raised);
.mode-label { text-transform: capitalize; } border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
padding: var(--sp-2) var(--sp-3);
display: flex;
align-items: center;
gap: var(--sp-2);
white-space: nowrap;
z-index: 100;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
font-size: var(--text-sm);
pointer-events: none;
animation: scaleIn 0.1s ease both;
}
.ch-popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
.ch-popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
.ch-pop-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-pop-sep { color: var(--text-faint); }
.ch-pop-name { color: var(--text-muted); }
.ch-pop-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.zoom-wrap { position: relative; flex-shrink: 0; } .bar-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
.zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
.zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); } .zoom-wrap { position: relative; flex-shrink: 0; }
.zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } .zoom-inline { display: flex; align-items: center; }
.zoom-step-btn:disabled { opacity: 0.25; cursor: default; } .bar-left .zoom-inline, .bar-right .zoom-inline { flex-direction: column; }
.zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); }
.zoom-icon-btn { width: 28px; height: 28px; }
.zoom-divider {
background: var(--border-dim);
flex-shrink: 0;
}
.bar-top .zoom-divider { width: 1px; height: 16px; }
.bar-left .zoom-divider,
.bar-right .zoom-divider { height: 1px; width: 16px; }
.zoom-pct-btn {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
color: var(--text-secondary);
height: 28px;
min-width: 38px;
text-align: center;
transition: color var(--t-base), background var(--t-base);
padding: 0 var(--sp-1);
border-radius: 0;
}
.bar-left .zoom-pct-btn,
.bar-right .zoom-pct-btn { height: 24px; min-width: unset; width: 28px; writing-mode: vertical-rl; font-size: 9px; padding: var(--sp-1) 0; }
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); } .zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
.popover {
position: absolute;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 100;
animation: scaleIn 0.1s ease both;
}
.popover-bottom { top: calc(100% + 6px); left: 50%; translate: -50% 0; transform-origin: top center; }
.popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
.popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
.zoom-popover { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); min-width: 180px; }
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); } .zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; } .zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; } .zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
@@ -341,7 +509,7 @@
.zoom-reset:disabled { opacity: 0.3; cursor: default; } .zoom-reset:disabled { opacity: 0.3; cursor: default; }
.marker-wrap { position: relative; flex-shrink: 0; } .marker-wrap { position: relative; flex-shrink: 0; }
.marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; } .marker-popover { width: 240px; padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); }
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; } .marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } .marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); } .marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
@@ -362,12 +530,68 @@
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; } .marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); } .marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
.wc-wrap { position: relative; flex-shrink: 0; } .wc-wrap { position: static; flex-shrink: 0; }
.wc-dropdown { position: absolute; top: calc(100% + 6px); right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); display: flex; flex-direction: column; gap: 2px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 148px; animation: scaleIn 0.1s ease both; transform-origin: top right; } .wc-clip {
.wc-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; width: 100%; transition: color var(--t-base), background var(--t-base); } position: absolute;
.wc-btn svg { flex-shrink: 0; opacity: 0.75; } z-index: 100;
.wc-btn:hover { color: var(--text-primary); background: var(--bg-overlay); } }
.wc-close:hover { color: #fff; background: #c0392b; } .wc-clip-bottom {
top: 100%;
right: var(--sp-3);
clip-path: inset(0 -20px -20px -20px);
}
.wc-clip-right {
left: calc(100% + 1px);
top: auto;
bottom: var(--sp-3);
clip-path: inset(-20px -20px -20px 0);
}
.wc-clip-left {
right: calc(100% + 1px);
top: auto;
bottom: var(--sp-3);
clip-path: inset(-20px 0 -20px -20px);
}
.wc-bar {
display: flex;
align-items: center;
gap: 1px;
padding: 3px 10px 4px;
background: var(--bg-raised);
border: 1px solid var(--border-base);
box-shadow: 0 6px 16px rgba(0,0,0,0.45);
}
.wc-clip-bottom .wc-bar { border-top: none; border-radius: 0 0 8px 8px; flex-direction: row; }
.wc-clip-right .wc-bar { border-left: none; border-radius: 0 8px 8px 0; flex-direction: column; padding: 10px 4px; }
.wc-clip-left .wc-bar { border-right: none; border-radius: 8px 0 0 8px; flex-direction: column; padding: 10px 4px; }
.wc-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 24px;
border-radius: var(--radius-sm);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: color var(--t-base), background var(--t-base);
flex-shrink: 0;
}
.wc-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.wc-icon-close:hover { color: #fff; background: #c0392b; }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
.bar-middle {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-height: 0;
padding: var(--sp-1) 0;
overflow: hidden;
}
</style> </style>
@@ -0,0 +1,754 @@
<script lang="ts">
import {
X, Check, Trash, FloppyDisk,
Square, Rows, BookOpen, MonitorPlay,
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
ArrowsHorizontal,
SidebarSimple,
} from "phosphor-svelte";
import type { ReaderSettings, ReaderPreset, FitMode } from "@store/state.svelte";
import { store, updateSettings } from "@store/state.svelte";
import { readerState, PAGE_STYLES, ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
interface Props {
fit: FitMode;
style: string;
rtl: boolean;
zoom: number;
zoomPct: number;
perMangaEnabled: boolean;
onTogglePerManga: () => void;
onSavePreset: (name: string) => void;
onApplyPreset: (settings: ReaderSettings) => void;
onUpdatePreset: (id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) => void;
onDeletePreset: (id: string) => void;
onApplySettings: (patch: Partial<ReaderSettings>) => void;
onCaptureZoomAnchor: () => void;
onRestoreZoomAnchor: () => void;
onClampZoom: (z: number) => number;
barPosition: "top" | "left" | "right";
onBarPositionChange: (pos: "top" | "left" | "right") => void;
}
const {
fit, style, rtl, zoom, zoomPct,
perMangaEnabled, onTogglePerManga,
onSavePreset, onApplyPreset, onUpdatePreset, onDeletePreset,
onApplySettings,
onCaptureZoomAnchor, onRestoreZoomAnchor, onClampZoom,
barPosition, onBarPositionChange,
}: Props = $props();
const presets = $derived(store.settings.readerPresets ?? []);
const effectiveSettings = $derived.by(() => {
const mangaId = store.activeManga?.id;
const override = mangaId != null ? (store.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
return override ? { ...store.settings, ...override } : store.settings;
});
let presetSaving = $state(false);
let presetNameInput = $state("");
let presetEditId = $state<string | null>(null);
let presetEditName = $state("");
function close() {
readerState.presetOpen = false;
presetSaving = false;
presetNameInput = "";
presetEditId = null;
}
function commitSavePreset() {
if (!presetNameInput.trim()) return;
onSavePreset(presetNameInput.trim());
presetSaving = false;
presetNameInput = "";
}
function commitRenamePreset() {
if (!presetEditId || !presetEditName.trim()) return;
onUpdatePreset(presetEditId, { name: presetEditName.trim() });
presetEditId = null;
presetEditName = "";
}
function describeSettings(s: ReaderSettings): string {
const parts = [s.pageStyle ?? "single", s.fitMode ?? "width", (s.readingDirection ?? "ltr") === "rtl" ? "RTL" : "LTR"];
if ((s.readerZoom ?? 1) !== 1.0) parts.push(`${Math.round((s.readerZoom ?? 1) * 100)}%`);
if (!s.pageGap) parts.push("no gap");
return parts.join(" · ");
}
function setZoom(v: number) {
onCaptureZoomAnchor();
onApplySettings({ readerZoom: onClampZoom(v) });
onRestoreZoomAnchor();
}
const fitOptions: { value: FitMode; label: string; icon: any }[] = [
{ value: "width", label: "Fit Width", icon: ArrowsLeftRight },
{ value: "height", label: "Fit Height", icon: ArrowsVertical },
{ value: "screen", label: "Fit Screen", icon: ArrowsIn },
{ value: "original", label: "Original", icon: ArrowsOut },
];
const styleOptions: { value: string; label: string; icon: any }[] = [
{ value: "single", label: "Single", icon: Square },
{ value: "double", label: "Double", icon: BookOpen },
{ value: "fade", label: "Fade", icon: MonitorPlay },
{ value: "longstrip", label: "Long Strip", icon: Rows },
];
const barOptions: { value: "top" | "left" | "right"; label: string }[] = [
{ value: "left", label: "Left" },
{ value: "top", label: "Top" },
{ value: "right", label: "Right" },
];
</script>
<div class="backdrop" role="presentation" onclick={close} transition:fade={{ duration: 150 }}></div>
<div
class="panel"
role="dialog"
aria-label="Reader settings & presets"
transition:fly={{ x: 320, duration: 220, easing: cubicOut }}
>
<div class="panel-header">
<span class="panel-title">Reader Settings</span>
{#if store.activeManga}
<span class="panel-manga">{store.activeManga.title}</span>
{/if}
<button class="close-btn" onclick={close}><X size={14} weight="light" /></button>
</div>
<div class="panel-body">
<section class="section">
<p class="section-label">Page Style</p>
<div class="option-grid">
{#each styleOptions as o}
<button
class="option-tile"
class:active={style === o.value}
onclick={() => onApplySettings({ pageStyle: o.value as typeof PAGE_STYLES[number] })}
>
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={style === o.value ? "fill" : "light"} /></div>
<span class="tile-label">{o.label}</span>
</button>
{/each}
</div>
{#if style === "double"}
<label class="toggle-row">
<span class="toggle-label">Offset double spreads</span>
<button
class="toggle"
class:on={effectiveSettings.offsetDoubleSpreads}
onclick={() => onApplySettings({ offsetDoubleSpreads: !effectiveSettings.offsetDoubleSpreads })}
role="switch"
aria-checked={effectiveSettings.offsetDoubleSpreads}
><span class="toggle-knob"></span></button>
</label>
{/if}
{#if style === "longstrip"}
<label class="toggle-row">
<span class="toggle-label">Gap between pages</span>
<button
class="toggle"
class:on={effectiveSettings.pageGap ?? true}
onclick={() => onApplySettings({ pageGap: !(effectiveSettings.pageGap ?? true) })}
role="switch"
aria-checked={effectiveSettings.pageGap ?? true}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Auto next chapter</span>
<button
class="toggle"
class:on={store.settings.autoNextChapter ?? false}
onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}
role="switch"
aria-checked={store.settings.autoNextChapter ?? false}
><span class="toggle-knob"></span></button>
</label>
{/if}
</section>
<section class="section">
<p class="section-label">Fit Mode</p>
<div class="option-grid">
{#each fitOptions as o}
<button
class="option-tile"
class:active={fit === o.value}
onclick={() => onApplySettings({ fitMode: o.value })}
>
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={fit === o.value ? "fill" : "light"} /></div>
<span class="tile-label">{o.label}</span>
</button>
{/each}
</div>
</section>
<section class="section">
<p class="section-label">Reading Direction</p>
<div class="dir-row">
<button
class="dir-btn"
class:active={!rtl}
onclick={() => onApplySettings({ readingDirection: "ltr" })}
>
<ArrowsHorizontal size={14} weight="light" />
<span>Left to Right</span>
</button>
<button
class="dir-btn"
class:active={rtl}
onclick={() => onApplySettings({ readingDirection: "rtl" })}
>
<ArrowsHorizontal size={14} weight="light" style="transform:scaleX(-1)" />
<span>Right to Left</span>
</button>
</div>
</section>
<section class="section">
<p class="section-label">Bar Position</p>
<div class="bar-grid">
{#each barOptions as o}
<button
class="bar-tile"
class:active={barPosition === o.value}
onclick={() => onBarPositionChange(o.value)}
>
<div class="bar-tile-preview bar-preview-{o.value}">
<div class="bar-preview-strip"></div>
<div class="bar-preview-content"></div>
</div>
<span class="tile-label">{o.label}</span>
</button>
{/each}
</div>
</section>
<section class="section">
<div class="section-header-row">
<p class="section-label" style="margin:0">Zoom</p>
<span class="zoom-readout">{zoomPct}%</span>
</div>
<div class="zoom-row">
<button class="zoom-step" onclick={() => setZoom(zoom - 0.1)} disabled={zoom <= ZOOM_MIN}></button>
<input
type="range"
class="zoom-slider"
min={Math.round(ZOOM_MIN * 100)}
max={Math.round(ZOOM_MAX * 100)}
step={5}
value={zoomPct}
oninput={(e) => setZoom(Number(e.currentTarget.value) / 100)}
/>
<button class="zoom-step" onclick={() => setZoom(zoom + 0.1)} disabled={zoom >= ZOOM_MAX}>+</button>
</div>
</section>
<section class="section">
<p class="section-label">Image</p>
<label class="toggle-row">
<span class="toggle-label">Optimize contrast</span>
<button
class="toggle"
class:on={effectiveSettings.optimizeContrast}
onclick={() => onApplySettings({ optimizeContrast: !effectiveSettings.optimizeContrast })}
role="switch"
aria-checked={effectiveSettings.optimizeContrast}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Pinch to zoom <span class="toggle-badge">experimental</span></span>
<button
class="toggle"
class:on={store.settings.pinchZoom ?? false}
onclick={() => updateSettings({ pinchZoom: !(store.settings.pinchZoom ?? false) })}
role="switch"
aria-checked={store.settings.pinchZoom ?? false}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Mark read on chapter advance</span>
<button
class="toggle"
class:on={store.settings.markReadOnNext ?? true}
onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}
role="switch"
aria-checked={store.settings.markReadOnNext ?? true}
><span class="toggle-knob"></span></button>
</label>
</section>
{#if store.activeManga}
<section class="section">
<label class="toggle-row">
<span class="toggle-label">Per-manga settings</span>
<button
class="toggle"
class:on={perMangaEnabled}
onclick={onTogglePerManga}
role="switch"
aria-checked={perMangaEnabled}
><span class="toggle-knob"></span></button>
</label>
</section>
{/if}
<section class="section">
<div class="section-header-row">
<p class="section-label" style="margin:0">Saved Presets</p>
{#if !presetSaving}
<button class="new-preset-btn" onclick={() => { presetSaving = true; presetNameInput = ""; }}>+ New</button>
{/if}
</div>
{#if presetSaving}
<div class="preset-name-row">
<input
class="preset-name-input"
placeholder="Preset name…"
bind:value={presetNameInput}
onkeydown={(e) => { if (e.key === "Enter") commitSavePreset(); if (e.key === "Escape") presetSaving = false; }}
/>
<button class="small-btn" disabled={!presetNameInput.trim()} onclick={commitSavePreset}><Check size={12} weight="bold" /></button>
<button class="small-btn" onclick={() => presetSaving = false}><X size={12} weight="light" /></button>
</div>
{/if}
{#if presets.length === 0 && !presetSaving}
<p class="empty-hint">No presets saved yet. Save the current settings to create one.</p>
{:else}
<div class="preset-list">
{#each presets as p (p.id)}
{#if presetEditId === p.id}
<div class="preset-name-row">
<input
class="preset-name-input"
bind:value={presetEditName}
onkeydown={(e) => { if (e.key === "Enter") commitRenamePreset(); if (e.key === "Escape") presetEditId = null; }}
/>
<button class="small-btn" disabled={!presetEditName.trim()} onclick={commitRenamePreset}><Check size={12} weight="bold" /></button>
<button class="small-btn" onclick={() => presetEditId = null}><X size={12} weight="light" /></button>
</div>
{:else}
<div class="preset-row">
<button class="preset-apply" onclick={() => { onApplyPreset(p.settings); close(); }}>
<span class="preset-name">{p.name}</span>
<span class="preset-desc">{describeSettings(p.settings)}</span>
</button>
<button class="small-btn" title="Rename" onclick={() => { presetEditId = p.id; presetEditName = p.name; }}>
<FloppyDisk size={12} weight="regular" />
</button>
<button class="small-btn danger" title="Delete" onclick={() => onDeletePreset(p.id)}>
<Trash size={12} weight="regular" />
</button>
</div>
{/if}
{/each}
</div>
{/if}
</section>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: calc(var(--z-reader) + 20);
background: rgba(0, 0, 0, 0.35);
}
.panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 320px;
z-index: calc(var(--z-reader) + 21);
background: var(--bg-surface);
border-left: 1px solid var(--border-base);
display: flex;
flex-direction: column;
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.5);
}
.panel-header {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-4);
height: 48px;
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.panel-title {
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
}
.panel-manga {
flex: 1;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
color: var(--text-muted);
flex-shrink: 0;
transition: color var(--t-base), background var(--t-base);
}
.close-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.panel-body {
flex: 1;
overflow-y: auto;
padding: var(--sp-3) var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-4);
scrollbar-width: thin;
scrollbar-color: var(--border-dim) transparent;
}
.section { display: flex; flex-direction: column; gap: var(--sp-2); }
.section-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
margin: 0 0 var(--sp-1);
}
.section-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-1);
}
.option-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-1);
}
.option-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--sp-2) var(--sp-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.option-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.option-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.tile-icon { display: flex; align-items: center; justify-content: center; }
.tile-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: capitalize; line-height: 1; }
.bar-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-1);
}
.bar-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--sp-2) var(--sp-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.bar-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.bar-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.bar-tile-preview {
width: 32px;
height: 22px;
border-radius: 3px;
border: 1px solid currentColor;
position: relative;
overflow: hidden;
opacity: 0.7;
display: flex;
}
.bar-tile.active .bar-tile-preview { opacity: 1; }
.bar-preview-strip {
background: currentColor;
opacity: 0.5;
flex-shrink: 0;
}
.bar-preview-content {
flex: 1;
background: color-mix(in srgb, currentColor 8%, transparent);
}
.bar-preview-top { flex-direction: column; }
.bar-preview-left { flex-direction: row; }
.bar-preview-right { flex-direction: row-reverse; }
.bar-preview-top .bar-preview-strip { height: 5px; width: 100%; }
.bar-preview-left .bar-preview-strip { width: 5px; height: 100%; }
.bar-preview-right .bar-preview-strip { width: 5px; height: 100%; }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-1) 0;
}
.toggle-label {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.toggle-badge {
font-family: var(--font-ui);
font-size: 9px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
border: 1px solid var(--border-dim);
border-radius: 3px;
padding: 1px 4px;
margin-left: var(--sp-1);
vertical-align: middle;
}
.toggle {
position: relative;
width: 32px;
height: 18px;
border-radius: 9px;
background: var(--border-strong);
border: none;
cursor: pointer;
flex-shrink: 0;
transition: background var(--t-base);
}
.toggle.on { background: var(--accent-fg); }
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
transition: left var(--t-base);
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.toggle.on .toggle-knob { left: 16px; }
.dir-row { display: flex; gap: var(--sp-2); }
.dir-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
font-size: var(--text-xs);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.dir-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.dir-btn.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.zoom-readout {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-secondary);
letter-spacing: var(--tracking-wide);
}
.zoom-row {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.zoom-step {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
font-size: var(--text-base);
line-height: 1;
flex-shrink: 0;
transition: color var(--t-fast), background var(--t-fast);
}
.zoom-step:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.zoom-step:disabled { opacity: 0.25; cursor: default; }
.zoom-slider {
flex: 1;
height: 3px;
appearance: none;
-webkit-appearance: none;
background: var(--border-strong);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-fg);
cursor: pointer;
box-shadow: 0 0 0 2px rgba(0,0,0,0.3);
}
.new-preset-btn {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--accent-fg);
letter-spacing: var(--tracking-wide);
background: none;
border: none;
cursor: pointer;
padding: 2px var(--sp-1);
border-radius: var(--radius-sm);
transition: background var(--t-fast);
}
.new-preset-btn:hover { background: var(--accent-muted); }
.preset-name-row { display: flex; align-items: center; gap: var(--sp-1); }
.preset-name-input {
flex: 1;
background: var(--bg-raised);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 5px 8px;
font-size: var(--text-xs);
color: var(--text-primary);
outline: none;
font-family: inherit;
transition: border-color var(--t-base);
}
.preset-name-input:focus { border-color: var(--accent-dim); }
.preset-list { display: flex; flex-direction: column; gap: 2px; }
.preset-row { display: flex; align-items: center; gap: var(--sp-1); }
.preset-apply {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 7px var(--sp-2);
border-radius: var(--radius-md);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background var(--t-fast);
min-width: 0;
}
.preset-apply:hover { background: var(--bg-overlay); }
.preset-name {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: var(--weight-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.preset-desc {
font-family: var(--font-ui);
font-size: 10px;
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.small-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
flex-shrink: 0;
transition: color var(--t-fast), background var(--t-fast);
}
.small-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); }
.small-btn:disabled { opacity: 0.25; cursor: default; }
.small-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.empty-hint {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
margin: 0;
padding: var(--sp-2) 0;
text-align: center;
}
</style>
@@ -17,6 +17,7 @@
activeChapterMarkers: MarkerEntry[]; activeChapterMarkers: MarkerEntry[];
adjacent: { prev: Chapter | null; next: Chapter | null }; adjacent: { prev: Chapter | null; next: Chapter | null };
uiVisible: boolean; uiVisible: boolean;
barPosition: "top" | "left" | "right";
onGoPrev: () => void; onGoPrev: () => void;
onGoNext: () => void; onGoNext: () => void;
onJumpToPage: (page: number) => void; onJumpToPage: (page: number) => void;
@@ -25,71 +26,126 @@
const { const {
style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage, style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage,
displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible, displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible,
barPosition,
onGoPrev, onGoNext, onJumpToPage, onGoPrev, onGoNext, onJumpToPage,
}: Props = $props(); }: Props = $props();
const isVertical = $derived(barPosition === "left" || barPosition === "right");
</script> </script>
<div class="bottombar" class:hidden={!uiVisible}> {#if !isVertical}
<button class="nav-btn" onclick={onGoPrev} <div class="bottombar" class:hidden={!uiVisible}>
disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}> <button class="nav-btn" onclick={onGoPrev}
<ArrowLeft size={13} weight="light" /> disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}>
</button> <ArrowLeft size={13} weight="light" />
</button>
{#if sliderMax > 1} {#if sliderMax > 1}
<div <div
class="slider-wrap" class="slider-wrap"
class:dragging={readerState.sliderDragging} class:dragging={readerState.sliderDragging}
role="slider" role="slider"
aria-valuenow={sliderPage} aria-valuenow={sliderPage}
aria-valuemin={1} aria-valuemin={1}
aria-valuemax={sliderMax} aria-valuemax={sliderMax}
tabindex="-1" tabindex="-1"
onmouseenter={() => readerState.sliderHover = true} onmouseenter={() => readerState.sliderHover = true}
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }} onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
onmousedown={(e) => { onmousedown={(e) => {
readerState.sliderDragging = true; readerState.sliderDragging = true;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1))); onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
}} }}
onmousemove={(e) => { onmousemove={(e) => {
if (!readerState.sliderDragging) return; if (!readerState.sliderDragging) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1))); onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
}} }}
onmouseup={() => readerState.sliderDragging = false} onmouseup={() => readerState.sliderDragging = false}
> >
<div class="slider-track-bg"> <div class="slider-track-bg">
<div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div> <div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div>
</div>
<div class="slider-thumb" style="left:{sliderPct}%"></div>
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
{@const bOrd = rtl ? lastPage - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
{@const bPct = lastPage > 1 ? ((bOrd - 1) / (lastPage - 1)) * 100 : 0}
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
{/if}
{#each activeChapterMarkers as m (m.id)}
{@const mOrd = rtl ? lastPage - m.pageNumber + 1 : m.pageNumber}
{@const mPct = lastPage > 1 ? ((mOrd - 1) / (lastPage - 1)) * 100 : 0}
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
{/each}
{#if readerState.sliderHover || readerState.sliderDragging}
<div class="slider-tooltip" style="left:{sliderPct}%">
{sliderPage} / {sliderMax}
</div> </div>
{/if} <div class="slider-thumb" style="left:{sliderPct}%"></div>
</div>
{/if}
<button class="nav-btn" onclick={onGoNext} {#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}> {@const bOrd = rtl ? sliderMax - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
<ArrowRight size={13} weight="light" /> {@const bPct = sliderMax > 1 ? ((bOrd - 1) / (sliderMax - 1)) * 100 : 0}
</button> <div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
</div> {/if}
{#each activeChapterMarkers as m (m.id)}
{@const mOrd = rtl ? sliderMax - m.pageNumber + 1 : m.pageNumber}
{@const mPct = sliderMax > 1 ? ((mOrd - 1) / (sliderMax - 1)) * 100 : 0}
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
{/each}
{#if readerState.sliderHover || readerState.sliderDragging}
<div class="slider-tooltip" style="left:{sliderPct}%">
{sliderPage} / {sliderMax}
</div>
{/if}
</div>
{/if}
<button class="nav-btn" onclick={onGoNext}
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}>
<ArrowRight size={13} weight="light" />
</button>
</div>
{:else}
<div class="vbar-progress" class:hidden={!uiVisible} class:vbar-right={barPosition === "right"}>
{#if sliderMax > 1}
<div
class="vslider-wrap"
class:dragging={readerState.sliderDragging}
role="slider"
aria-valuenow={sliderPage}
aria-valuemin={1}
aria-valuemax={sliderMax}
tabindex="-1"
onmouseenter={() => readerState.sliderHover = true}
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
onmousedown={(e) => {
readerState.sliderDragging = true;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
}}
onmousemove={(e) => {
if (!readerState.sliderDragging) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
}}
onmouseup={() => readerState.sliderDragging = false}
>
<div class="vslider-track-bg">
<div class="vslider-fill" style="height:{sliderPct}%"></div>
</div>
<div class="vslider-thumb" style="top:{sliderPct}%"></div>
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div class="vslider-checkpoint bookmark-checkpoint" style="top:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
{/if}
{#each activeChapterMarkers as m (m.id)}
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div class="vslider-checkpoint marker-checkpoint" style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
{/each}
{#if readerState.sliderHover || readerState.sliderDragging}
<div class="vslider-tooltip" style="top:{sliderPct}%" class:tooltip-right={barPosition === "right"}>
{sliderPage} / {sliderMax}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<style> <style>
.bottombar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; } .bottombar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
@@ -109,4 +165,91 @@
.marker-checkpoint { opacity: 0.85; } .marker-checkpoint { opacity: 0.85; }
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); } .slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; } .slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
.vbar-progress {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
width: 100%;
padding: var(--sp-2) 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.vbar-progress.hidden { opacity: 0; }
.vslider-wrap {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 36px;
cursor: pointer;
pointer-events: all;
margin: var(--sp-1) 0;
}
.vslider-track-bg {
position: absolute;
top: 0;
bottom: 0;
width: 5px;
background: var(--border-strong);
border-radius: 3px;
pointer-events: none;
left: 50%;
translate: -50% 0;
}
.vslider-fill {
width: 100%;
background: var(--accent-fg);
border-radius: 3px;
transition: height 0.05s linear;
}
.vslider-thumb {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-fg);
pointer-events: none;
z-index: 2;
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
transition: transform var(--t-fast);
}
.vslider-wrap:hover .vslider-thumb, .vslider-wrap.dragging .vslider-thumb { transform: translate(-50%, -50%) scale(1.3); }
.vslider-wrap:hover .vslider-track-bg, .vslider-wrap.dragging .vslider-track-bg { width: 7px; }
.vslider-checkpoint {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 5px;
border-radius: 2px;
pointer-events: none;
z-index: 1;
}
.vslider-tooltip {
position: absolute;
left: calc(100% + 6px);
transform: translateY(-50%);
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-sm);
padding: 2px 6px;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-secondary);
white-space: nowrap;
pointer-events: none;
z-index: 10;
letter-spacing: var(--tracking-wide);
}
.vslider-tooltip.tooltip-right {
left: auto;
right: calc(100% + 6px);
}
</style> </style>
+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 };
}
+1 -1
View File
@@ -56,4 +56,4 @@ export function createReaderKeyHandler(actions: ReaderKeyActions): (e: KeyboardE
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); } else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); }
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); } else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); }
}; };
} }
@@ -31,6 +31,8 @@ class ReaderState {
dlOpen = $state(false); dlOpen = $state(false);
zoomOpen = $state(false); zoomOpen = $state(false);
winOpen = $state(false); winOpen = $state(false);
presetOpen = $state(false);
presetNameInput = $state("");
nextN = $state(5); nextN = $state(5);
dlBusy = $state(false); dlBusy = $state(false);
@@ -80,10 +82,11 @@ class ReaderState {
} }
closeAllPopovers(): boolean { closeAllPopovers(): boolean {
if (this.markerOpen) { this.markerOpen = false; return true; } if (this.markerOpen) { this.markerOpen = false; return true; }
if (this.zoomOpen) { this.zoomOpen = false; return true; } if (this.zoomOpen) { this.zoomOpen = false; return true; }
if (this.dlOpen) { this.dlOpen = false; return true; } if (this.dlOpen) { this.dlOpen = false; return true; }
if (this.winOpen) { this.winOpen = false; return true; } if (this.winOpen) { this.winOpen = false; return true; }
if (this.presetOpen) { this.presetOpen = false; return true; }
return false; return false;
} }
@@ -104,4 +107,4 @@ class ReaderState {
} }
} }
export const readerState = new ReaderState(); export const readerState = new ReaderState();
@@ -173,7 +173,7 @@
</div> </div>
<div class="list-header-right"> <div class="list-header-right">
<!-- Jump to chapter -->
<div class="jump-wrap"> <div class="jump-wrap">
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter"> <button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
<MagnifyingGlass size={14} weight="light" /> <MagnifyingGlass size={14} weight="light" />
@@ -191,7 +191,7 @@
{/if} {/if}
</div> </div>
<!-- Scanlator filter -->
{#if availableScanlators.length > 1} {#if availableScanlators.length > 1}
<div class="scan-filter-wrap"> <div class="scan-filter-wrap">
<button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator"> <button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
@@ -245,12 +245,12 @@
</div> </div>
{/if} {/if}
<!-- Refresh -->
<button class="icon-btn" onclick={onRefresh} disabled={refreshing}> <button class="icon-btn" onclick={onRefresh} disabled={refreshing}>
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} /> <ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button> </button>
<!-- Folder picker -->
<div class="fp-wrap" bind:this={folderPickerRef}> <div class="fp-wrap" bind:this={folderPickerRef}>
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}> <button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} /> <FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
@@ -283,7 +283,7 @@
{/if} {/if}
</div> </div>
<!-- Download dropdown -->
{#if chapters.length > 0} {#if chapters.length > 0}
<div class="dl-wrap" bind:this={dlDropRef}> <div class="dl-wrap" bind:this={dlDropRef}>
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options"> <button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
@@ -343,7 +343,7 @@
</div> </div>
{/if} {/if}
<!-- Top pagination -->
{#if totalPages > 1} {#if totalPages > 1}
<div class="pagination"> <div class="pagination">
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}></button> <button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}></button>
@@ -355,7 +355,6 @@
</div> </div>
<style> <style>
/* ─── Header bar ──────────────────────────────────────────── */
.list-header { .list-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim);
@@ -364,7 +363,6 @@
.list-header-left, .list-header-left,
.list-header-right { display: flex; align-items: center; gap: var(--sp-1); } .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
/* ─── Sort ────────────────────────────────────────────────── */
.sort-btn { .sort-btn {
display: flex; align-items: center; gap: 5px; display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
@@ -390,7 +388,6 @@
.sort-option.active { color: var(--accent-fg); } .sort-option.active { color: var(--accent-fg); }
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); } .sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
/* ─── Icon buttons ────────────────────────────────────────── */
.icon-btn { .icon-btn {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-md); width: 28px; height: 28px; border-radius: var(--radius-md);
@@ -402,7 +399,6 @@
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ─── Jump ────────────────────────────────────────────────── */
.jump-wrap { position: relative; } .jump-wrap { position: relative; }
.jump-popover { .jump-popover {
position: absolute; top: calc(100% + 4px); right: 0; width: 220px; position: absolute; top: calc(100% + 4px); right: 0; width: 220px;
@@ -429,7 +425,6 @@
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); } .jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); } .jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
/* ─── Folder picker ───────────────────────────────────────── */
.fp-wrap { position: relative; } .fp-wrap { position: relative; }
.fp-menu { .fp-menu {
position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px;
@@ -476,7 +471,6 @@
} }
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); } .fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
/* ─── Download dropdown ───────────────────────────────────── */
.dl-wrap { position: relative; } .dl-wrap { position: relative; }
.dl-dropdown { .dl-dropdown {
position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px;
@@ -545,7 +539,6 @@
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; } .dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* ─── Pagination (top) ────────────────────────────────────── */
.pagination { display: flex; align-items: center; gap: var(--sp-2); } .pagination { display: flex; align-items: center; gap: var(--sp-2); }
.page-btn { .page-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
@@ -557,7 +550,6 @@
.page-btn:disabled { opacity: 0.3; cursor: default; } .page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
/* ─── Selection toolbar ───────────────────────────────────── */
.sel-count { .sel-count {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1);
@@ -572,7 +564,6 @@
.sel-action-danger { color: var(--color-error) !important; } .sel-action-danger { color: var(--color-error) !important; }
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; } .sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
/* ─── Scanlator filter ────────────────────────────────────── */
.scan-filter-wrap { position: relative; } .scan-filter-wrap { position: relative; }
.scan-filter-panel { .scan-filter-panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px; position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px;
@@ -637,6 +628,5 @@
.scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; } .scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; }
.scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; } .scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; }
/* ─── Shared animation (used by dropdowns/popovers) ───────── */
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style> </style>
@@ -18,6 +18,7 @@
checkAndMarkCompleted as storeCheckAndMarkCompleted, checkAndMarkCompleted as storeCheckAndMarkCompleted,
clearMarkersForManga, clearMarkersForManga,
} from "@store/state.svelte"; } from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import type { MangaPrefs } from "@store/state.svelte"; import type { MangaPrefs } from "@store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte"; import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import type { Manga, Chapter, Category } from "@types"; import type { Manga, Chapter, Category } from "@types";
@@ -80,18 +81,21 @@
const scanlatorBlacklist = $derived((get("scanlatorBlacklist") ?? []) as string[]); const scanlatorBlacklist = $derived((get("scanlatorBlacklist") ?? []) as string[]);
const scanlatorForce = $derived((get("scanlatorForce") ?? false) as boolean); const scanlatorForce = $derived((get("scanlatorForce") ?? false) as boolean);
const currentPrefs = $derived({
sortMode,
sortDir,
preferredScanlator: get("preferredScanlator") as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
});
const availableScanlators = $derived( const availableScanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))] [...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b)) .sort((a, b) => a.localeCompare(b))
); );
const sortedChapters = $derived(buildChapterList(chapters, { const sortedChapters = $derived(buildChapterList(chapters, currentPrefs));
sortMode, sortDir,
preferredScanlator: get("preferredScanlator") as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
}));
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE)); const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE)); const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
@@ -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,7 +161,8 @@
function applyChapters(nodes: Chapter[]) { function applyChapters(nodes: Chapter[]) {
if (get("autoDownload") && _prevChapterIds.size > 0) { if (get("autoDownload") && _prevChapterIds.size > 0) {
const newChapters = nodes.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded); const filtered = buildChapterList(nodes, currentPrefs);
const newChapters = filtered.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id)); if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
} }
_prevChapterIds = new Set(nodes.map(c => c.id)); _prevChapterIds = new Set(nodes.map(c => c.id));
@@ -249,9 +251,33 @@
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; }); }).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
} }
async function syncTrackersIntoChapters(mangaId: number, chaps: Chapter[]) {
if (!store.settings.trackerSyncBack) return;
const records = trackingState.recordsFor(mangaId);
if (!records.length) return;
for (const record of records) {
try {
const { markedIds } = await trackingState.syncFromRemote(mangaId, record, chaps, currentPrefs);
if (markedIds.length > 0) {
const idSet = new Set(markedIds);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead: true } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
} catch {}
}
}
$effect(() => { $effect(() => {
const m = store.activeManga; const m = store.activeManga;
if (m) untrack(() => { acknowledgeUpdate(m.id); loadManga(m.id); loadChapters(m.id); loadCategories(m.id); }); if (m) untrack(() => {
acknowledgeUpdate(m.id);
loadManga(m.id);
loadChapters(m.id);
loadCategories(m.id);
trackingState.loadForManga(m.id).then(() => {
syncTrackersIntoChapters(m.id, chapters);
});
});
}); });
let prevChapterId: number | null = null; let prevChapterId: number | null = null;
@@ -298,9 +324,21 @@
} }
async function markRead(chapterId: number, isRead: boolean) { async function markRead(chapterId: number, isRead: boolean) {
const mangaId = store.activeManga?.id;
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error); await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c); chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); } if (mangaId) {
chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
checkAndMarkCompleted(mangaId, chapters);
const ch = chapters.find(c => c.id === chapterId);
if (ch) {
if (isRead) {
await trackingState.updateFromRead(mangaId, ch, chapters, currentPrefs);
} else {
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs);
}
}
}
if (isRead) { if (isRead) {
if (get("deleteOnRead")) { if (get("deleteOnRead")) {
const ch = chapters.find(c => c.id === chapterId); const ch = chapters.find(c => c.id === chapterId);
@@ -323,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>
+73 -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;
@@ -382,6 +417,42 @@
flex: 1; flex: 1;
accent-color: var(--accent); accent-color: var(--accent);
cursor: pointer; cursor: pointer;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: var(--border-strong);
outline: none;
}
.s-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
transition: transform var(--t-fast), box-shadow var(--t-fast);
}
.s-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
}
.s-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
}
.s-slider::-moz-range-track {
height: 4px;
border-radius: 2px;
background: var(--border-strong);
} }
.s-slider-val { .s-slider-val {
@@ -65,9 +65,9 @@
let listeningKey: keyof Keybinds | null = $state(null); let listeningKey: keyof Keybinds | null = $state(null);
$effect(() => { $effect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !listeningKey) close(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !listeningKey) { e.stopPropagation(); close(); } };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey, true);
}); });
$effect(() => { $effect(() => {
@@ -118,7 +118,7 @@
<div class="s-backdrop" role="presentation" tabindex="-1" <div class="s-backdrop" role="presentation" tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) close(); }} onclick={(e) => { if (e.target === e.currentTarget) close(); }}
onkeydown={(e) => { if (e.key === "Escape") close(); }}> onkeydown={(e) => { if (e.key === "Escape") { e.stopPropagation(); close(); } }}>
<div class="s-modal" role="dialog" aria-label="Settings"> <div class="s-modal" role="dialog" aria-label="Settings">
<div class="s-sidebar"> <div class="s-sidebar">
@@ -162,7 +162,7 @@
{#if tab === "general"} {#if tab === "general"}
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} /> <GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === "appearance"} {:else if tab === "appearance"}
<AppearanceSettings {onOpenThemeEditor} /> <AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {anims} {onOpenThemeEditor} />
{:else if tab === "reader"} {:else if tab === "reader"}
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} /> <ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === "library"} {:else if tab === "library"}
@@ -3,9 +3,10 @@
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { open as openUrl } from "@tauri-apps/plugin-shell"; import { open as openUrl } from "@tauri-apps/plugin-shell";
import { autoBackupAppData } from "@core/backup";
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string; } interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string; }
type UpdatePhase = "idle" | "downloading" | "ready" | "error"; type UpdatePhase = "idle" | "downloading" | "launching" | "ready" | "error";
const IS_WINDOWS = navigator.userAgent.includes("Windows"); const IS_WINDOWS = navigator.userAgent.includes("Windows");
let appVersion = $state("…"); let appVersion = $state("…");
@@ -33,6 +34,13 @@
return () => unlisten?.(); return () => unlisten?.();
}); });
$effect(() => {
let unlisten: (() => void) | undefined;
listen("update-launching", () => { updatePhase = "launching"; })
.then(fn => { unlisten = fn; });
return () => unlisten?.();
});
async function loadReleases() { async function loadReleases() {
releasesLoading = true; releasesError = null; releasesLoading = true; releasesError = null;
try { try {
@@ -80,8 +88,9 @@
targetTag = release.tag_name; updatePhase = "downloading"; updateError = null; dlBytes = 0; dlTotal = null; targetTag = release.tag_name; updatePhase = "downloading"; updateError = null; dlBytes = 0; dlTotal = null;
try { try {
if (IS_WINDOWS) { if (IS_WINDOWS) {
await autoBackupAppData();
try { await invoke("kill_server"); } catch {} try { await invoke("kill_server"); } catch {}
await invoke("download_and_install_update"); await invoke("download_and_install_update", { tag: release.tag_name });
updatePhase = "ready"; updatePhase = "ready";
} else { } else {
await openUrl(release.html_url); await openUrl(release.html_url);
@@ -134,6 +143,11 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if updatePhase === "launching"}
<div class="s-update-ready">
<span class="s-update-ready-label">Launching installer for {targetTag}</span>
</div>
{/if}
{#if updatePhase === "ready"} {#if updatePhase === "ready"}
<div class="s-update-ready"> <div class="s-update-ready">
<span class="s-update-ready-label">{targetTag} downloaded — restart to finish installing.</span> <span class="s-update-ready-label">{targetTag} downloaded — restart to finish installing.</span>
@@ -207,7 +221,7 @@
<p class="s-section-title">Links</p> <p class="s-section-title">Links</p>
<div class="s-section-body"> <div class="s-section-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)"> <div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)">
<a href="https://github.com/Youwes09/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a> <a href="https://github.com/moku-project/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a> <a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a>
</div> </div>
</div> </div>
@@ -1,25 +1,102 @@
<script lang="ts"> <script lang="ts">
import { Pencil, Trash, Plus } from "phosphor-svelte"; import { Pencil, Trash, Plus } from "phosphor-svelte";
import { store, updateSettings, deleteCustomTheme } from "@store/state.svelte"; import { store, updateSettings, deleteCustomTheme } from "@store/state.svelte";
import { mountSystemThemeSync } from "@core/theme";
import { selectPortal } from "@core/actions/selectPortal";
interface Props { interface Props {
selectOpen: string | null;
closingSelect: string | null;
toggleSelect: (id: string) => void;
anims: boolean;
onOpenThemeEditor?: (id?: string | null) => void; onOpenThemeEditor?: (id?: string | null) => void;
} }
let { onOpenThemeEditor }: Props = $props(); let { selectOpen, closingSelect, toggleSelect, anims, onOpenThemeEditor }: Props = $props();
const THEMES: { id: string; label: string; description: string; swatches: string[] }[] = [ const THEMES: { id: string; label: string; description: string; swatches: string[] }[] = [
{ id: "dark", label: "Dark", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] }, { id: "original", label: "Original", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] },
{ id: "high-contrast", label: "High Contrast", description: "Darker base, sharper text", swatches: ["#080808","#111111","#bcd8bc","#ffffff"] }, { id: "dark", label: "Dark", description: "Darker base, sharper text", swatches: ["#080808","#111111","#bcd8bc","#ffffff"] },
{ id: "light", label: "Light", description: "Warm off-white", swatches: ["#f4f2ee","#faf8f4","#2a5a2a","#1a1916"] }, { id: "light", label: "Light", description: "Warm off-white", swatches: ["#f4f2ee","#faf8f4","#2a5a2a","#1a1916"] },
{ id: "light-contrast", label: "Light Contrast", description: "Light with maximum contrast", swatches: ["#ece8e2","#f5f2ec","#183818","#080806"] },
{ id: "midnight", label: "Midnight", description: "Deep blue-black tint", swatches: ["#0c1020","#101428","#a8b4e8","#eeeef8"] }, { id: "midnight", label: "Midnight", description: "Deep blue-black tint", swatches: ["#0c1020","#101428","#a8b4e8","#eeeef8"] },
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] }, { id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
]; ];
const allThemeOptions = $derived([
...THEMES.map(t => ({ id: t.id, label: t.label })),
...(store.settings.customThemes ?? []).map(t => ({ id: t.id, label: t.name })),
]);
function toggleSync() {
updateSettings({ systemThemeSync: !store.settings.systemThemeSync });
mountSystemThemeSync();
}
let triggerDark: HTMLButtonElement;
let triggerLight: HTMLButtonElement;
</script> </script>
<div class="s-panel"> <div class="s-panel">
<div class="s-section">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Match system theme</span>
<span class="s-desc">Automatically switch theme when your OS switches between light and dark</span>
</div>
<button
class="s-toggle"
class:on={store.settings.systemThemeSync}
onclick={toggleSync}
role="switch"
aria-checked={store.settings.systemThemeSync}
><span class="s-toggle-thumb"></span></button>
</div>
{#if store.settings.systemThemeSync}
<div class="s-sync-pair">
<div class="s-sync-item">
<span class="s-sync-label">Dark theme</span>
<div class="s-select">
<button bind:this={triggerDark} class="s-select-btn" onclick={() => toggleSelect("sync-dark")}>
<span>{allThemeOptions.find(o => o.id === (store.settings.systemThemeDark ?? "dark"))?.label ?? "Original"}</span>
<svg class="s-select-caret" class:open={selectOpen === "sync-dark"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "sync-dark" || closingSelect === "sync-dark"}
<div class="s-select-menu" class:anims class:closing={closingSelect === "sync-dark"} {@attach selectPortal(triggerDark)}>
{#each allThemeOptions as opt}
<button class="s-select-option" class:active={opt.id === (store.settings.systemThemeDark ?? "dark")}
onclick={() => { updateSettings({ systemThemeDark: opt.id }); mountSystemThemeSync(); toggleSelect("sync-dark"); }}>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-sync-item">
<span class="s-sync-label">Light theme</span>
<div class="s-select">
<button bind:this={triggerLight} class="s-select-btn" onclick={() => toggleSelect("sync-light")}>
<span>{allThemeOptions.find(o => o.id === (store.settings.systemThemeLight ?? "light"))?.label ?? "Light"}</span>
<svg class="s-select-caret" class:open={selectOpen === "sync-light"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "sync-light" || closingSelect === "sync-light"}
<div class="s-select-menu" class:anims class:closing={closingSelect === "sync-light"} {@attach selectPortal(triggerLight)}>
{#each allThemeOptions as opt}
<button class="s-select-option" class:active={opt.id === (store.settings.systemThemeLight ?? "light")}
onclick={() => { updateSettings({ systemThemeLight: opt.id }); mountSystemThemeSync(); toggleSelect("sync-light"); }}>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
<div class="s-section"> <div class="s-section">
<p class="s-section-title">Theme</p> <p class="s-section-title">Theme</p>
<div class="s-theme-grid"> <div class="s-theme-grid">
@@ -19,6 +19,10 @@
<div class="s-section"> <div class="s-section">
<p class="s-section-title">Display</p> <p class="s-section-title">Display</p>
<div class="s-section-body"> <div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Always show card stats</span><span class="s-desc">Show unread and download counts without needing to hover</span></div>
<button role="switch" aria-checked={store.settings.libraryStatsAlways ?? false} aria-label="Always show card stats" class="s-toggle" class:on={store.settings.libraryStatsAlways ?? false} onclick={() => updateSettings({ libraryStatsAlways: !(store.settings.libraryStatsAlways ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row"> <label class="s-row">
<div class="s-row-info"><span class="s-label">Crop cover images</span><span class="s-desc">Fills the card with the cover art instead of letterboxing</span></div> <div class="s-row-info"><span class="s-label">Crop cover images</span><span class="s-desc">Fills the card with the cover art instead of letterboxing</span></div>
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="s-toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="s-toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="s-toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="s-toggle-thumb"></span></button>
@@ -27,6 +31,12 @@
<div class="s-row-info"><span class="s-label">Show all in Saved tab</span><span class="s-desc">Include manga that are in folders — lets you see your whole library in one place</span></div> <div class="s-row-info"><span class="s-label">Show all in Saved tab</span><span class="s-desc">Include manga that are in folders — lets you see your whole library in one place</span></div>
<button role="switch" aria-checked={store.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={store.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(store.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={store.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(store.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button>
</label> </label>
{#if store.settings.libraryShowAllInSaved ?? true}
<label class="s-row">
<div class="s-row-info"><span class="s-label">Hide completed in Saved tab</span><span class="s-desc">Keep manga in the Completed folder out of the Saved view</span></div>
<button role="switch" aria-checked={store.settings.libraryHideCompletedInSaved ?? false} aria-label="Hide completed manga in Saved tab" class="s-toggle" class:on={store.settings.libraryHideCompletedInSaved ?? false} onclick={() => updateSettings({ libraryHideCompletedInSaved: !(store.settings.libraryHideCompletedInSaved ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
{/if}
</div> </div>
</div> </div>
@@ -8,6 +8,7 @@
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads"; import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { store, updateSettings, addToast } from "@store/state.svelte"; import { store, updateSettings, addToast } from "@store/state.svelte";
import { exportAppData, importAppData } from "@core/backup";
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; } interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
@@ -52,8 +53,9 @@
let extraScanDirs = $state<string[]>([...(store.settings.extraScanDirs ?? [])]); let extraScanDirs = $state<string[]>([...(store.settings.extraScanDirs ?? [])]);
let newScanDir = $state(""); let newScanDir = $state("");
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]); let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
let advStorageOpen = $state(false); let advStorageOpen = $state(false);
let backupSectionOpen = $state(false); let backupSectionOpen = $state(false);
let appDataSectionOpen = $state(false);
async function fetchStorage() { async function fetchStorage() {
storageLoading = true; storageError = null; storageLoading = true; storageError = null;
@@ -324,6 +326,39 @@
finally { validateLoading = false; } finally { validateLoading = false; }
} }
let appDataExporting = $state(false);
let appDataImporting = $state(false);
let appDataError = $state<string | null>(null);
let appDataMsg = $state<string | null>(null);
let appDataBackupDir = $state<string | null>(null);
$effect(() => {
invoke<string>("get_auto_backup_dir").then(d => { appDataBackupDir = d; }).catch(() => {});
});
async function handleExportAppData() {
appDataExporting = true; appDataError = null; appDataMsg = null;
try {
await exportAppData();
appDataMsg = "Backup saved.";
setTimeout(() => appDataMsg = null, 3000);
} catch (e: any) {
if (String(e).includes("Cancelled")) return;
appDataError = e?.message ?? String(e);
} finally { appDataExporting = false; }
}
async function handleImportAppData() {
appDataImporting = true; appDataError = null; appDataMsg = null;
try {
await importAppData();
} catch (e: any) {
if (String(e).includes("Cancelled")) { appDataImporting = false; return; }
appDataError = e?.message ?? String(e);
appDataImporting = false;
}
}
$effect(() => { untrack(() => { loadBackupList(); fetchStorage(); }); }); $effect(() => { untrack(() => { loadBackupList(); fetchStorage(); }); });
$effect(() => { return () => stopRestorePoll(); }); $effect(() => { return () => stopRestorePoll(); });
</script> </script>
@@ -512,7 +547,6 @@
{#if !isExternalServer} {#if !isExternalServer}
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button> <button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -638,4 +672,56 @@
{/if} {/if}
</div> </div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => appDataSectionOpen = !appDataSectionOpen}>
<span class="s-label">App-Data Backup</span>
<svg class="s-collapsible-caret" class:open={appDataSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if appDataSectionOpen}
<div class="s-collapsible-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Export settings</span>
<span class="s-desc">Save all Moku app settings to a JSON file via a native save dialog.</span>
</div>
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
{appDataExporting ? "Saving…" : "Export"}
</button>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Import settings</span>
<span class="s-desc">Restore from a previously exported JSON file. Reloads the app immediately.</span>
</div>
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
{appDataImporting ? "Importing…" : "Import"}
</button>
</div>
{#if appDataError}
<div class="s-banner s-banner-error">{appDataError}</div>
{/if}
{#if appDataMsg}
<div class="s-row">
<span class="s-desc" style="color:var(--color-success,#4caf50)">{appDataMsg}</span>
</div>
{/if}
{#if appDataBackupDir}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Auto-backup location</span>
<span class="s-desc">Pre-update snapshots are kept here (last 5).</span>
</div>
<button class="s-btn" onclick={() => invoke("open_path", { path: appDataBackupDir })}>Open folder</button>
</div>
{/if}
</div>
{/if}
</div>
</div> </div>
@@ -3,7 +3,12 @@
import { GET_TRACKERS } from "@api/queries/tracking"; import { GET_TRACKERS } from "@api/queries/tracking";
import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking"; import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking";
import { open as openUrl } from "@tauri-apps/plugin-shell"; import { open as openUrl } from "@tauri-apps/plugin-shell";
import type { Tracker } from "../../lib/types"; import { store, updateSettings, addToast } from "@store/state.svelte";
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
import { GET_ALL_TRACKER_RECORDS } from "@api/queries/tracking";
import { GET_CHAPTERS } from "@api/queries/chapters";
import type { Tracker, TrackRecord } from "../../lib/types";
import type { Chapter } from "@types/index";
let trackers = $state<Tracker[]>([]); let trackers = $state<Tracker[]>([]);
let trackersLoading = $state(false); let trackersLoading = $state(false);
@@ -78,6 +83,42 @@
} }
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); } function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
let syncing = $state(false);
async function runSyncAll() {
syncing = true;
try {
const res = await gql<{ trackers: { nodes: any[] } }>(GET_ALL_TRACKER_RECORDS);
const allTrackers = res.trackers.nodes.filter((t: any) => t.isLoggedIn);
let totalMarked = 0;
for (const tracker of allTrackers) {
for (const record of tracker.trackRecords.nodes as TrackRecord[]) {
if (!record.manga?.id) continue;
const mangaId = record.manga.id;
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
const marked = await syncBackFromTracker(
[record],
chapRes.chapters.nodes,
{
threshold: store.settings.trackerSyncBackThreshold ?? null,
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
(query, vars) => gql(query, vars),
);
totalMarked += marked.length;
}
}
addToast({ kind: "success", title: "Sync complete", body: `${totalMarked} chapter${totalMarked !== 1 ? "s" : ""} marked read` });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { syncing = false; }
}
</script> </script>
<div class="s-panel"> <div class="s-panel">
@@ -148,4 +189,64 @@
</div> </div>
</div> </div>
<div class="s-section">
<p class="s-section-title">Sync back from tracker</p>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Enable sync back</span>
<span class="s-desc">Mark chapters read locally based on tracker progress</span>
</div>
<button class="s-toggle" class:on={store.settings.trackerSyncBack}
onclick={() => updateSettings({ trackerSyncBack: !store.settings.trackerSyncBack })}
role="switch" aria-checked={store.settings.trackerSyncBack}>
<span class="s-toggle-thumb"></span>
</button>
</div>
{#if store.settings.trackerSyncBack}
<label class="s-row">
<div class="s-row-info">
<span class="s-label">Chapter number tolerance</span>
<span class="s-desc">Allow source and tracker chapter numbers to differ by up to the set amount. When off, the tracker number is used as-is with no range check.</span>
</div>
<button role="switch" aria-checked={store.settings.trackerSyncBackThreshold !== null} class="s-toggle" class:on={store.settings.trackerSyncBackThreshold !== null}
onclick={() => updateSettings({ trackerSyncBackThreshold: store.settings.trackerSyncBackThreshold !== null ? null : 20 })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
{#if store.settings.trackerSyncBackThreshold !== null}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Tolerance</span><span class="s-desc">Max chapter number difference allowed (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>
+5 -5
View File
@@ -113,7 +113,11 @@
}); });
$effect(() => { $effect(() => {
if (!ringFull) return; if (!ringFull) {
exitLock = false;
exiting = false;
return;
}
cancelAnimationFrame(animFrame); cancelAnimationFrame(animFrame);
ringProg = 1; ringProg = 1;
if (lockEnabled && !pinUnlocked) { if (lockEnabled && !pinUnlocked) {
@@ -163,8 +167,6 @@
return () => clearInterval(dotsInterval); return () => clearInterval(dotsInterval);
}); });
// ── Canvas card animation ─────────────────────────────────────────────────
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; } interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
interface CardTrig { cosA: number; sinA: number; tiltRad: number; } interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; } interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
@@ -177,7 +179,6 @@
const BUF = 80, COLS = 14; const BUF = 80, COLS = 14;
// Deterministic per-index hash — no random(), same layout every mount
function hash(n: number): number { function hash(n: number): number {
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b); let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b); x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
@@ -275,7 +276,6 @@
for (let i = 0; i < cards.length; i++) { for (let i = 0; i < cards.length; i++) {
const c = cards[i]; const c = cards[i];
const p = ((t / c.cycleSec) + c.phase) % 1; const p = ((t / c.cycleSec) + c.phase) % 1;
// Fade in at entry, fade out at exit
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha; const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
if (alpha < 0.005) continue; if (alpha < 0.005) continue;
const cy = c.yStart - p * c.travel; const cy = c.yStart - p * c.travel;
+14 -9
View File
@@ -1,7 +1,8 @@
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { probeServer, loginBasic } from "@core/auth"; import { probeServer, loginBasic } from "@core/auth";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
const MAX_ATTEMPTS = 10; const MAX_ATTEMPTS = 40;
export const boot = $state({ export const boot = $state({
serverProbeOk: false, serverProbeOk: false,
@@ -15,24 +16,25 @@ export const boot = $state({
loginBusy: false, loginBusy: false,
}); });
let cancelProbe = false; let probeGeneration = 0;
export function startProbe() { export function startProbe() {
cancelProbe = false; const gen = ++probeGeneration;
boot.failed = false; boot.failed = false;
boot.loginRequired = false; boot.loginRequired = false;
boot.unsupportedMode = false; boot.unsupportedMode = false;
let tries = 0; let tries = 0;
async function probe() { async function probe() {
if (cancelProbe) return; if (gen !== probeGeneration) return;
tries++; tries++;
const result = await probeServer(); const result = await probeServer();
if (cancelProbe) return; if (gen !== probeGeneration) return;
if (result === "ok") { if (result === "ok") {
boot.serverProbeOk = true; boot.serverProbeOk = true;
boot.loginRequired = false; boot.loginRequired = false;
trackingState.bootSync().catch(() => {});
return; return;
} }
@@ -44,6 +46,7 @@ export function startProbe() {
try { try {
await loginBasic(savedUser, savedPass); await loginBasic(savedUser, savedPass);
boot.loginRequired = false; boot.loginRequired = false;
trackingState.bootSync().catch(() => {});
return; return;
} catch {} } catch {}
} }
@@ -59,14 +62,15 @@ export function startProbe() {
} }
if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; } if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; }
setTimeout(probe, 750); const delay = Math.min(750 + tries * 250, 3000);
setTimeout(probe, delay);
} }
setTimeout(probe, 800); setTimeout(probe, 2000);
} }
export function stopProbe() { export function stopProbe() {
cancelProbe = true; probeGeneration++;
} }
export async function submitLogin(onSuccess: () => void) { export async function submitLogin(onSuccess: () => void) {
@@ -81,6 +85,7 @@ export async function submitLogin(onSuccess: () => void) {
boot.loginRequired = false; boot.loginRequired = false;
boot.loginPass = ""; boot.loginPass = "";
boot.loginError = null; boot.loginError = null;
trackingState.bootSync().catch(() => {});
onSuccess(); onSuccess();
} catch (e: any) { } catch (e: any) {
boot.loginError = e?.message ?? "Login failed"; boot.loginError = e?.message ?? "Login failed";
@@ -99,7 +104,7 @@ export function retryBoot() {
} }
export function bypassBoot(onReady: () => void) { export function bypassBoot(onReady: () => void) {
cancelProbe = true; probeGeneration++;
boot.serverProbeOk = true; boot.serverProbeOk = true;
boot.loginRequired = false; boot.loginRequired = false;
boot.unsupportedMode = false; boot.unsupportedMode = false;
+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" },
]; ];
+89 -184
View File
@@ -1,167 +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[];
}
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownload: false, downloadAhead: 0, deleteOnRead: false,
deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false,
refreshInterval: "global", preferredScanlator: "", scanlatorFilter: [],
};
export interface Settings {
pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode;
readerZoom: number; pageGap: boolean; optimizeContrast: boolean;
offsetDoubleSpreads: boolean; preloadPages: number;
autoMarkRead: boolean; autoNextChapter: boolean;
libraryCropCovers: boolean; libraryPageSize: number;
showNsfw: boolean; discordRpc: boolean;
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number;
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean;
serverUrl: string; serverBinary: string; autoStartServer: boolean;
preferredExtensionLang: string; keybinds: Keybinds;
idleTimeoutMin?: number; splashCards?: boolean;
storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number;
autoBookmark: boolean; theme: Theme; libraryBranches: boolean; renderLimit: number;
heroSlots: (number | null)[]; mangaLinks: Record<number, number[]>;
mangaPrefs: Record<number, Partial<MangaPrefs>>;
serverAuthUser: string; serverAuthPass: string;
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
socksProxyVersion: number; socksProxyUsername: string; socksProxyPassword: string;
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
flareSolverrSessionName: string; flareSolverrSessionTtl: number; flareSolverrFallback: boolean;
appLockEnabled: boolean; appLockPin: string;
customThemes: CustomTheme[]; hiddenCategoryIds: number[];
defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean;
nsfwFilteredTags: string[]; nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[];
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
libraryTabStatus: Record<string, LibraryStatusFilter>;
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
maxPageWidth?: number; uiScale?: number;
extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string;
qolAnimations: boolean;
}
export const DEFAULT_READING_STATS: ReadingStats = {
totalChaptersRead: 0, totalMangaRead: 0, totalMinutesRead: 0,
firstReadAt: 0, lastReadAt: 0, currentStreakDays: 0, longestStreakDays: 0, lastStreakDate: "",
};
export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip", readingDirection: "ltr", fitMode: "width",
readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false,
preloadPages: 3, autoMarkRead: true, autoNextChapter: true,
libraryCropCovers: true, libraryPageSize: 48, showNsfw: false, discordRpc: false,
chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
serverUrl: "http://localhost:4567", serverBinary: "", autoStartServer: true,
preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS,
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true,
theme: "dark", libraryBranches: true, renderLimit: 48,
heroSlots: [null, null, null, null], mangaLinks: {}, mangaPrefs: {},
serverAuthUser: "", serverAuthPass: "", serverAuthMode: "NONE",
socksProxyEnabled: false, socksProxyHost: "", socksProxyPort: "1080",
socksProxyVersion: 5, socksProxyUsername: "", socksProxyPassword: "",
flareSolverrEnabled: false, flareSolverrUrl: "http://localhost:8191",
flareSolverrTimeout: 60, flareSolverrSessionName: "moku",
flareSolverrSessionTtl: 15, flareSolverrFallback: false,
appLockEnabled: false, appLockPin: "",
customThemes: [], hiddenCategoryIds: [], defaultLibraryCategoryId: null,
savedIsDefaultCategory: false,
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [],
libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {},
extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "",
qolAnimations: true,
};
const STORE_VERSION = 3;
const 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"];
@@ -204,33 +66,37 @@ function mergeSettings(saved: any): Settings {
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {}, libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {}, libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
extraScanDirs: saved?.settings?.extraScanDirs ?? [], extraScanDirs: saved?.settings?.extraScanDirs ?? [],
pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [],
readerPresets: saved?.settings?.readerPresets ?? [],
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
}; };
} }
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 });
searchCache: Map<string, any> = $state(new Map()); dailyReadCounts: Record<string, number> = $state(saved?.dailyReadCounts ?? {});
searchLibraryIds: Set<number> = $state(new Set()); searchCache: Map<string, any> = $state(new Map());
searchSrcOffset: number = $state(0); searchLibraryIds: Set<number> = $state(new Set());
readerSessionId: number = $state(0); searchSrcOffset: number = $state(0);
libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []); readerSessionId: number = $state(0);
lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0); libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []);
acknowledgedUpdates: Set<number> = $state(new Set(saved?.acknowledgedUpdateIds ?? [])); lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0);
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; }
@@ -250,6 +116,7 @@ class Store {
settings: this.settings, history: this.history, settings: this.settings, history: this.history,
bookmarks: this.bookmarks, markers: this.markers, bookmarks: this.bookmarks, markers: this.markers,
readLog: this.readLog, readingStats: this.readingStats, readLog: this.readLog, readingStats: this.readingStats,
dailyReadCounts: this.dailyReadCounts,
libraryUpdates: this.libraryUpdates, libraryUpdates: this.libraryUpdates,
lastLibraryRefresh: this.lastLibraryRefresh, lastLibraryRefresh: this.lastLibraryRefresh,
acknowledgedUpdateIds: [...this.acknowledgedUpdates], acknowledgedUpdateIds: [...this.acknowledgedUpdates],
@@ -288,6 +155,8 @@ class Store {
lastReadAt: entry.readAt, currentStreakDays: streak, lastReadAt: entry.readAt, currentStreakDays: streak,
longestStreakDays: Math.max(this.readingStats.longestStreakDays, streak), lastStreakDate: todayStr, longestStreakDays: Math.max(this.readingStats.longestStreakDays, streak), lastStreakDate: todayStr,
}; };
const dayKey = new Date().toISOString().slice(0, 10);
this.dailyReadCounts = { ...this.dailyReadCounts, [dayKey]: (this.dailyReadCounts[dayKey] ?? 0) + 1 };
} }
} }
@@ -314,7 +183,7 @@ class Store {
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); } getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); }
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); } getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); }
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); } clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); }
clearHistory() { this.history = []; this.readLog = []; } clearHistory() { this.history = []; this.readLog = []; this.dailyReadCounts = {}; }
clearHistoryForManga(mangaId: number) { clearHistoryForManga(mangaId: number) {
this.history = this.history.filter(x => x.mangaId !== mangaId); this.history = this.history.filter(x => x.mangaId !== mangaId);
@@ -329,6 +198,7 @@ class Store {
wipeAllData() { wipeAllData() {
this.history = []; this.readLog = []; this.markers = []; this.history = []; this.readLog = []; this.markers = [];
this.dailyReadCounts = {};
this.readingStats = { ...DEFAULT_READING_STATS }; this.readingStats = { ...DEFAULT_READING_STATS };
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} }; this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
} }
@@ -404,6 +274,35 @@ class Store {
} }
} }
togglePinnedSource(sourceId: string) {
const pins = this.settings.pinnedSourceIds ?? [];
this.settings = { ...this.settings, pinnedSourceIds: pins.includes(sourceId) ? pins.filter(id => id !== sourceId) : [...pins, sourceId] };
}
saveReaderPreset(name: string, settings: ReaderSettings): string {
const id = Math.random().toString(36).slice(2);
this.settings = { ...this.settings, readerPresets: [...(this.settings.readerPresets ?? []), { id, name: name.trim() || "Preset", settings }] };
return id;
}
updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) {
this.settings = { ...this.settings, readerPresets: (this.settings.readerPresets ?? []).map(p => p.id === id ? { ...p, ...patch } : p) };
}
deleteReaderPreset(id: string) {
this.settings = { ...this.settings, readerPresets: (this.settings.readerPresets ?? []).filter(p => p.id !== id) };
}
setMangaReaderSettings(mangaId: number, settings: ReaderSettings) {
this.settings = { ...this.settings, mangaReaderSettings: { ...(this.settings.mangaReaderSettings ?? {}), [mangaId]: settings } };
}
clearMangaReaderSettings(mangaId: number) {
const next = { ...(this.settings.mangaReaderSettings ?? {}) };
delete next[mangaId];
this.settings = { ...this.settings, mangaReaderSettings: next };
}
setCategories(cats: Category[]) { this.categories = cats; } setCategories(cats: Category[]) { this.categories = cats; }
setActiveManga(next: Manga | null) { this.activeManga = next; } setActiveManga(next: Manga | null) { this.activeManga = next; }
setPreviewManga(next: Manga | null) { this.previewManga = next; } setPreviewManga(next: Manga | null) { this.previewManga = next; }
@@ -436,6 +335,12 @@ export function setPageUrls(next: string[])
export function setPageNumber(next: number) { store.setPageNumber(next); } export function setPageNumber(next: number) { store.setPageNumber(next); }
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); } export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); } export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
export function togglePinnedSource(sourceId: string) { store.togglePinnedSource(sourceId); }
export function saveReaderPreset(name: string, settings: ReaderSettings): string { return store.saveReaderPreset(name, settings); }
export function updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) { store.updateReaderPreset(id, patch); }
export function deleteReaderPreset(id: string) { store.deleteReaderPreset(id); }
export function setMangaReaderSettings(mangaId: number, settings: ReaderSettings) { store.setMangaReaderSettings(mangaId, settings); }
export function clearMangaReaderSettings(mangaId: number) { store.clearMangaReaderSettings(mangaId); }
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); } export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
export function resetKeybinds() { store.resetKeybinds(); } export function resetKeybinds() { store.resetKeybinds(); }
export function clearSearchCache() { store.clearSearchCache(); } export function clearSearchCache() { store.clearSearchCache(); }
+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,
};