Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 023b23288b | |||
| 67a9f0b944 | |||
| 56392e2427 | |||
| 843e205072 | |||
| ee708d85d0 | |||
| 8005c82654 | |||
| d989b2d67e | |||
| 6446a19b2d | |||
| 5cd96abc0c | |||
| db44afc4dc | |||
| 4248e344ab | |||
| 8941bfef10 | |||
| 11cd6ff870 | |||
| 15adb02be3 | |||
| 51bb6cdab9 | |||
| 454a674ada | |||
| f146de5c02 | |||
| 04f680c3bb | |||
| f49f7e7ac1 | |||
| a62512bf42 | |||
| d91ed2e6d1 | |||
| 61e3c4ee2f | |||
| 9151820843 | |||
| 63c890dadf | |||
| 51a33679d5 | |||
| 82f8a9a36b | |||
| 4decce9a7f | |||
| a69d5eacc5 | |||
| 4959722759 | |||
| 35ba0171c7 | |||
| d26fa50e76 | |||
| fd9d216325 | |||
| 581eb2adb0 | |||
| 8aa2dc2547 | |||
| 0a11fe3982 | |||
| f6786def87 | |||
| 262027d9f9 | |||
| d407359973 | |||
| a77572a8d4 | |||
| 32d2fffdc5 | |||
| e850cbac1e | |||
| eebd1b6446 | |||
| 5ed072211b | |||
| 62e41e5f07 | |||
| 4b6d0780c9 | |||
| 6ef0facb89 | |||
| 34d997fc9d | |||
| 1f08b46919 | |||
| ac6b70fb32 | |||
| 2c93d8743d | |||
| b9fe54c08d | |||
| 3abb4bb96c | |||
| 4b3493465d | |||
| 2163f4a8a6 | |||
| fc535f3f74 | |||
| c819d03222 | |||
| b23292cff5 | |||
| 6d85be751a | |||
| 06a9e71a90 | |||
| 1a183e7a24 | |||
| dcb3377349 | |||
| 077ea4dd8f | |||
| 6bdf59db6a | |||
| db9ff33c64 | |||
| fb1b3d9789 | |||
| 041f735a6e | |||
| a27c20fabf | |||
| 29323c534b | |||
| a3ef693ed8 | |||
| 4691f3aed7 |
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to build (e.g. 0.3.0)"
|
description: "Version to build (e.g. 0.4.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -100,149 +100,86 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
|
|
||||||
find_launcher() {
|
stage_arch() {
|
||||||
local dir="$1"
|
local srcdir="$1"
|
||||||
# v2.1.1867 macOS tarball ships "Suwayomi Launcher.command" (space, .command)
|
local arch="$2"
|
||||||
find "$dir" -maxdepth 1 -type f -name "*.command" | head -1
|
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
|
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||||
|
|
||||||
|
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||||
|
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$JAR" ]; then
|
||||||
|
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
||||||
|
find "$srcdir" -type f | head -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$JAVA" ]; then
|
||||||
|
echo "ERROR: jre/bin/java not found in $srcdir"
|
||||||
|
find "$srcdir" -type f | head -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${arch}: jar=${JAR} java=${JAVA}"
|
||||||
|
|
||||||
|
cp -r "$srcdir" "$bundle_dest"
|
||||||
|
|
||||||
|
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
|
||||||
|
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
|
||||||
|
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
|
||||||
|
chmod +x "$sidecar"
|
||||||
|
echo "Staged sidecar: $sidecar"
|
||||||
}
|
}
|
||||||
|
|
||||||
ARM_LAUNCHER=$(find_launcher suwayomi-arm64)
|
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
||||||
X64_LAUNCHER=$(find_launcher suwayomi-x64)
|
stage_arch suwayomi-x64 x86_64-apple-darwin
|
||||||
|
|
||||||
if [ -z "$ARM_LAUNCHER" ] || [ -z "$X64_LAUNCHER" ]; then
|
|
||||||
echo "ERROR: could not find launchers — tarball contents:"
|
|
||||||
ls -lR suwayomi-arm64 suwayomi-x64
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "arm64 launcher: $ARM_LAUNCHER"
|
|
||||||
echo "x64 launcher: $X64_LAUNCHER"
|
|
||||||
|
|
||||||
cp "$ARM_LAUNCHER" src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
|
|
||||||
cp "$X64_LAUNCHER" src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
|
|
||||||
chmod +x src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
|
|
||||||
chmod +x src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
|
|
||||||
|
|
||||||
# tauri.conf.json expects exactly "binaries/suwayomi-bundle".
|
|
||||||
# We stage both arch bundles and swap the symlink before each build.
|
|
||||||
cp -r suwayomi-arm64 src-tauri/binaries/suwayomi-bundle-arm64
|
|
||||||
cp -r suwayomi-x64 src-tauri/binaries/suwayomi-bundle-x64
|
|
||||||
|
|
||||||
- name: Patch tauri.conf.json for CI
|
- name: Patch tauri.conf.json for CI
|
||||||
run: |
|
run: |
|
||||||
# dist/ is already built by the frontend job — suppress the rebuild.
|
|
||||||
# We patch in-place rather than using --config to avoid Tauri schema issues.
|
|
||||||
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
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-arm64 src-tauri/binaries/suwayomi-bundle
|
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
||||||
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
- name: Build Tauri app (aarch64)
|
- name: Build Tauri app (aarch64)
|
||||||
uses: tauri-apps/tauri-action@v0
|
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
# Ad-hoc signing ("-") ships without a Developer ID.
|
||||||
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
# Gatekeeper will quarantine the app on other Macs — users must run:
|
||||||
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
# 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 || '-' }}
|
||||||
with:
|
|
||||||
args: --target aarch64-apple-darwin
|
|
||||||
|
|
||||||
|
# ── 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
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-x64 src-tauri/binaries/suwayomi-bundle
|
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
||||||
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
- name: Build Tauri app (x86_64)
|
- name: Build Tauri app (x86_64)
|
||||||
uses: tauri-apps/tauri-action@v0
|
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
|
||||||
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
with:
|
|
||||||
args: --target x86_64-apple-darwin
|
|
||||||
|
|
||||||
|
# ── upload artifacts ───────────────────────────────────────────────────
|
||||||
- name: Upload arm64 .dmg
|
- name: Upload arm64 .dmg
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: moku-aarch64
|
name: moku-macos-arm64-${{ github.event.inputs.version }}
|
||||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload x64 .dmg
|
- name: Upload x64 .dmg
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: moku-x86_64
|
name: moku-macos-x64-${{ github.event.inputs.version }}
|
||||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload arm64 .app (for universal job)
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: app-aarch64-apple-darwin
|
|
||||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/macos/
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
- name: Upload x64 .app (for universal job)
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: app-x86_64-apple-darwin
|
|
||||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/macos/
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
universal:
|
|
||||||
name: Universal .dmg
|
|
||||||
needs: tauri
|
|
||||||
runs-on: macos-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download arm64 .app
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: app-aarch64-apple-darwin
|
|
||||||
path: apps/arm64/
|
|
||||||
|
|
||||||
- name: Download x64 .app
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: app-x86_64-apple-darwin
|
|
||||||
path: apps/x64/
|
|
||||||
|
|
||||||
- name: lipo into universal binary
|
|
||||||
run: |
|
|
||||||
ARM_APP=$(find apps/arm64 -name "*.app" -maxdepth 1 | head -1)
|
|
||||||
X64_APP=$(find apps/x64 -name "*.app" -maxdepth 1 | head -1)
|
|
||||||
APP_NAME=$(basename "$ARM_APP")
|
|
||||||
|
|
||||||
mkdir -p universal
|
|
||||||
cp -r "$ARM_APP" "universal/${APP_NAME}"
|
|
||||||
|
|
||||||
find "universal/${APP_NAME}" -type f | while read -r f; do
|
|
||||||
if file "$f" | grep -q "Mach-O"; then
|
|
||||||
X64_EQUIV="${X64_APP}${f#universal/${APP_NAME}}"
|
|
||||||
if [ -f "$X64_EQUIV" ]; then
|
|
||||||
lipo -create -output "$f" "$f" "$X64_EQUIV" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Package universal .dmg
|
|
||||||
run: |
|
|
||||||
APP_NAME=$(find universal -name "*.app" -maxdepth 1 | head -1 | xargs basename)
|
|
||||||
mkdir dmg-stage
|
|
||||||
cp -r "universal/${APP_NAME}" dmg-stage/
|
|
||||||
ln -s /Applications dmg-stage/Applications
|
|
||||||
hdiutil create \
|
|
||||||
-volname "Moku" \
|
|
||||||
-srcfolder dmg-stage \
|
|
||||||
-ov -format UDZO \
|
|
||||||
"moku-universal.dmg"
|
|
||||||
|
|
||||||
- name: Upload universal .dmg
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: moku-universal
|
|
||||||
path: moku-universal.dmg
|
|
||||||
retention-days: 7
|
|
||||||
@@ -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
|
||||||
@@ -93,8 +96,6 @@ jobs:
|
|||||||
else
|
else
|
||||||
cp -r suwayomi-raw/. suwayomi-extracted/
|
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||||
fi
|
fi
|
||||||
echo "Extracted bundle contents (top-level):"
|
|
||||||
ls -la suwayomi-extracted/
|
|
||||||
|
|
||||||
- name: Stage Suwayomi bundle
|
- name: Stage Suwayomi bundle
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -103,17 +104,15 @@ jobs:
|
|||||||
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||||
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||||
if [ -z "$JAVA" ]; then
|
if [ -z "$JAVA" ]; then
|
||||||
echo "ERROR: jre/bin/java.exe not found. Bundle contents:"
|
echo "ERROR: jre/bin/java.exe not found"
|
||||||
find suwayomi-extracted -type f | head -50
|
find suwayomi-extracted -type f | head -50
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ -z "$JAR" ]; then
|
if [ -z "$JAR" ]; then
|
||||||
echo "ERROR: Suwayomi-Server.jar not found. Bundle contents:"
|
echo "ERROR: Suwayomi-Server.jar not found"
|
||||||
find suwayomi-extracted -type f | head -50
|
find suwayomi-extracted -type f | head -50
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Found java: $JAVA"
|
|
||||||
echo "Found jar: $JAR"
|
|
||||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
- name: Validate staging
|
- name: Validate staging
|
||||||
@@ -129,19 +128,35 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
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
|
||||||
echo "tauri.conf.json patched:"
|
|
||||||
cat src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
- name: Build Tauri app (Windows x64)
|
- name: Delete existing draft release if present
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
echo "Deleting existing draft release $RELEASE_ID"
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID"
|
||||||
|
# Also delete the tag so tauri-action can recreate it
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||||
|
echo "Deleted draft release and tag"
|
||||||
|
else
|
||||||
|
echo "No existing draft release found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Tauri app + create draft release
|
||||||
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 }}
|
||||||
|
releaseName: Moku v${{ github.event.inputs.version }}
|
||||||
|
releaseBody: |
|
||||||
|
Windows installer for Moku v${{ github.event.inputs.version }}.
|
||||||
|
Download the `.exe` file below to install or update.
|
||||||
|
releaseDraft: true
|
||||||
|
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
|
||||||
|
|
||||||
- name: Upload Windows installer
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: moku-windows-x64
|
|
||||||
path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
|
||||||
retention-days: 7
|
|
||||||
|
|||||||
@@ -37,5 +37,7 @@ src-tauri/gen/
|
|||||||
# --- Flatpak build artifacts ---
|
# --- Flatpak build artifacts ---
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
|
dist/
|
||||||
|
packaging/frontend-dist.tar.gz
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.4.0
|
pkgver=0.5.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,46 +1,113 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="src/assets/moku-icon-rounded.svg" width="96" />
|
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||||
<h1>Moku</h1>
|
</div>
|
||||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and Svelte.</p>
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||||
|
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||||
|
[](./LICENSE)
|
||||||
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
||||||
|
<img src="docs/screenshots/Moku-Discover.png" width="49%" alt="Discover" />
|
||||||
|
<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 align="center">
|
||||||
|
<a href="docs/screenshots">View all screenshots →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
## Features
|
||||||
|
|
||||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||||
|
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
|
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
||||||
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
|
- **Auto-updates** — in-app update checker with silent background notifications
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
**Nix (recommended)**
|
### Flatpak (Linux, recommended)
|
||||||
|
|
||||||
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:Youwes09/moku
|
flatpak install moku.flatpak
|
||||||
|
flatpak run dev.moku.app
|
||||||
|
```
|
||||||
|
|
||||||
|
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run github:Youwes09/Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
Add to your flake:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.moku.url = "github:Youwes09/moku";
|
inputs.moku.url = "github:Youwes09/Moku";
|
||||||
```
|
```
|
||||||
|
|
||||||
**From source**
|
### Windows
|
||||||
|
|
||||||
```bash
|
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||||
git clone https://github.com/Youwes09/moku
|
|
||||||
cd moku
|
### macOS
|
||||||
nix build
|
|
||||||
./result/bin/moku
|
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||||
```
|
|
||||||
|
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||||
|
> ```bash
|
||||||
|
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||||
|
|
||||||
|
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Youwes09/Moku
|
||||||
|
cd Moku
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Nix:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix develop
|
nix develop
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -54,12 +121,20 @@ pnpm tauri:dev
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||||
| [Svelte](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
Questions, feedback, or just want to hang out — join the Discord.
|
||||||
|
|
||||||
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||||
|
|||||||
@@ -1,104 +1,43 @@
|
|||||||
Todo:
|
Major Revisions:
|
||||||
3. Explore Manga Upscaler & Other Image Processing
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
4. Font Weird on Flatpak, Investigate and Fix
|
|
||||||
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
|
Minor Revisions:
|
||||||
|
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
||||||
|
|
||||||
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
|
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||||
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
|
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||||
|
- Adjustment in Settings for Theme Editor:
|
||||||
|
- Patch Color-Picker to Work Properly
|
||||||
|
- Moku Discord RPC
|
||||||
|
- Write a better library for Discord RPC & Tauri
|
||||||
|
- Integrate Download Directory Changes (Settings)
|
||||||
|
|
||||||
|
Priority Bugs:
|
||||||
|
- Cache ALL Cover Pictures & Details for Manga in Library
|
||||||
|
- Fix Library Build not Updating
|
||||||
|
- Check Auth System (Only Supports Basic-Auth)
|
||||||
|
|
||||||
|
|
||||||
Bugs:
|
General/Misc Bugs:
|
||||||
|
- Fix Highlightable Elements
|
||||||
- Add Back after Search & Clear on Search
|
- Investigate "egl:failed to create dri2 screen"
|
||||||
- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
|
- Check Fonts/Design on Flatpak
|
||||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
- Fix Delete-All Crash (Deletes All but Cripples App)
|
||||||
|
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
||||||
|
|
||||||
|
|
||||||
- Fix Infinite Scroll Hitting Button Non-reactive to Chapter State, hence Resulting in Error. (Doesn't work on single or double digit, but works on select chapters?) (doesnt work 1 - 2 qnd 51 - 52?) Cause unknow
|
In-Progress:
|
||||||
- - Reader appears to be adding integers? Marks chapters incorrectly, need to stablize and patch. User is able to
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
skip chapters, etc
|
|
||||||
- Mark as Read no longer working on select chapters, choose more robust methodology.
|
|
||||||
- Reset to top when user clicks next chapter in reader.
|
|
||||||
|
|
||||||
|
- Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
|
||||||
|
|
||||||
- Fix Downloaded in Library (Tags Broken) & All
|
|
||||||
- Using Delete All Crashes App (But Works)
|
|
||||||
- Fix Folder Display in Library
|
|
||||||
- Add Version Tags (To Find Version)
|
|
||||||
- Sidebar Icon Highlighted
|
|
||||||
- Introduce Deduplication into Library & Search
|
|
||||||
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Add PDF Textbook Support
|
|
||||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
|
||||||
- Migration Features
|
|
||||||
- Multi-Page Long Screenshot
|
|
||||||
- Add Consumet Api (Anime & Light Novel Support)
|
|
||||||
|
|
||||||
|
|
||||||
Big Revisions:
|
|
||||||
0. Expand into fully-fledged reader, with modular manga support
|
|
||||||
1. Anime & Novel Support
|
|
||||||
2. Tracker Support
|
|
||||||
3. Cloudflare Bypass Enable Support
|
|
||||||
4. macOS Support (feasible)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Testing:
|
Testing:
|
||||||
|
|
||||||
- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
|
- Fix TitleBar not Appearing on Windows in Fullscreen (Locks in User)
|
||||||
- Fix the Mark as Read (Glitched)
|
- Integrate Download Directory Changes (Settings)
|
||||||
|
- Fix Source Allow in Content (Doesn't even work)
|
||||||
|
|
||||||
Completed:
|
|
||||||
8. Fix Polling on Download Manager (Instantanous Response)
|
|
||||||
19. Debounce Time on Reader to improve lag (Toggle Setting)
|
|
||||||
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
|
|
||||||
17. Change Library Text change to "No manga saved to library, browse sources to add some."
|
|
||||||
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
|
|
||||||
7. Fix Scaling (100 = 125% and so forth)
|
|
||||||
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
|
|
||||||
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
|
|
||||||
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
|
|
||||||
11. Reader & UI needs download and other Notifications
|
|
||||||
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
|
|
||||||
- Add Refresh Details on Series Details.
|
|
||||||
- Patch GenreDrill & Integrate into Explore Folder
|
|
||||||
18. Disable NSFW Extensions option in settings
|
|
||||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
|
||||||
- Remove Series Detail Mark Read & Unread
|
|
||||||
20. Expand History (Total Time Read, etc)
|
|
||||||
12. Delete all Downloads should also cancel all download queues
|
|
||||||
13. Cancel Download along with Queue & Download Timeout Feature
|
|
||||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
|
||||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
|
||||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
|
||||||
- Extensions Page no Longer Loading efficiently
|
|
||||||
- Map out MangaPreview tags to GenreDrill
|
|
||||||
- GenreDrill & GenreFilter pages do not populate completely.
|
|
||||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
|
||||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
|
||||||
- Clean up Migrate Model to be more initutive
|
|
||||||
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
|
||||||
5. Lock reader on valid chapters to avoid bugs, etc.
|
|
||||||
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
|
||||||
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
|
||||||
- Properly Kill Tachidesk-Server
|
|
||||||
- Fix scaling on splash screen
|
|
||||||
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
|
|
||||||
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
|
|
||||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Important Commands:
|
|
||||||
cd ~/Projects/Manga/Moku
|
|
||||||
pnpm build
|
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
|
||||||
|
|
||||||
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
|
||||||
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
|
||||||
3. flatpak build-bundle repo moku.flatpak dev.moku.app
|
|
||||||
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: c78a3f002f898011c4e70e1af781b37dac0fd995b5623170256d88339c90ca74
|
sha256: d3ebde4d39e3de61420b78a9506df1a5c77c14d705e42662a45a2179bc96030e
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
|
||||||
|
<defs>
|
||||||
|
|
||||||
|
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#52b888"/>
|
||||||
|
<stop offset="100%" stop-color="#1e5840"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<clipPath id="roundedBounds">
|
||||||
|
<rect width="1280" height="320" rx="18" ry="18"/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g clip-path="url(#roundedBounds)">
|
||||||
|
|
||||||
|
<rect width="1280" height="320" fill="#070e09"/>
|
||||||
|
|
||||||
|
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
|
||||||
|
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
|
||||||
|
fill="url(#leafHero)" opacity="0.97">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Stack text pinned to bottom -->
|
||||||
|
<text
|
||||||
|
x="640" y="300"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
|
||||||
|
font-size="14"
|
||||||
|
letter-spacing="5"
|
||||||
|
fill="#a8c4a8"
|
||||||
|
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 7.1 MiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 914 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 609 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 151 KiB |
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem = { system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.4.0";
|
version = "0.7.1";
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
inherit version;
|
inherit version;
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 1;
|
||||||
hash = "sha256-FsZTHeBS9qQ9KYgiwDX1vam6uJXK8OjLe5U6Jfu33lc=";
|
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
buildPhase = "pnpm build";
|
||||||
@@ -149,7 +149,7 @@ EOF
|
|||||||
|
|
||||||
bumpScript = pkgs.writeShellApplication {
|
bumpScript = pkgs.writeShellApplication {
|
||||||
name = "moku-bump";
|
name = "moku-bump";
|
||||||
runtimeInputs = with pkgs; [ gnused coreutils git ];
|
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ];
|
||||||
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"
|
||||||
@@ -160,6 +160,7 @@ EOF
|
|||||||
"$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"
|
||||||
|
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||||
echo "Bumped to $VERSION"
|
echo "Bumped to $VERSION"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
@@ -263,6 +264,15 @@ EOF
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
tunnelScript = pkgs.writeShellApplication {
|
||||||
|
name = "moku-tunnel";
|
||||||
|
runtimeInputs = with pkgs; [ cloudflared ];
|
||||||
|
text = ''
|
||||||
|
PORT="''${1:-4567}"
|
||||||
|
cloudflared tunnel --url "http://localhost:$PORT"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
apps = {
|
apps = {
|
||||||
@@ -271,6 +281,7 @@ EOF
|
|||||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
bump = { type = "app"; program = "${bumpScript}/bin/moku-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"; };
|
||||||
};
|
};
|
||||||
|
|
||||||
packages = {
|
packages = {
|
||||||
@@ -287,6 +298,7 @@ EOF
|
|||||||
nodejs_22
|
nodejs_22
|
||||||
pnpm
|
pnpm
|
||||||
suwayomi-server
|
suwayomi-server
|
||||||
|
cloudflared
|
||||||
xdg-utils
|
xdg-utils
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
@@ -300,6 +312,7 @@ EOF
|
|||||||
echo " nix run .#bump -- <ver> bump versions only"
|
echo " nix run .#bump -- <ver> bump versions only"
|
||||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
echo " nix run .#flatpak -- <ver> full flatpak build"
|
||||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
||||||
|
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moku",
|
"name": "moku",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -11,9 +11,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.8",
|
||||||
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"phosphor-svelte": "^3.1.0",
|
"phosphor-svelte": "^3.1.0",
|
||||||
"svelte-spa-router": "^4.0.1"
|
"svelte-spa-router": "^4.0.1",
|
||||||
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||||
|
"tauri-plugin-drpc": "^1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ importers:
|
|||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
|
'@tauri-apps/plugin-http':
|
||||||
|
specifier: ^2.5.8
|
||||||
|
version: 2.5.8
|
||||||
|
'@tauri-apps/plugin-os':
|
||||||
|
specifier: ^2.3.2
|
||||||
|
version: 2.3.2
|
||||||
|
'@tauri-apps/plugin-shell':
|
||||||
|
specifier: ^2.3.5
|
||||||
|
version: 2.3.5
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@@ -20,6 +29,12 @@ importers:
|
|||||||
svelte-spa-router:
|
svelte-spa-router:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
|
tauri-plugin-discord-rpc-api:
|
||||||
|
specifier: github:Youwes09/tauri-plugin-discord-rpc
|
||||||
|
version: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a
|
||||||
|
tauri-plugin-drpc:
|
||||||
|
specifier: ^1.0.3
|
||||||
|
version: 1.0.3(typescript@5.9.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@sveltejs/vite-plugin-svelte':
|
'@sveltejs/vite-plugin-svelte':
|
||||||
specifier: ^4.0.4
|
specifier: ^4.0.4
|
||||||
@@ -433,6 +448,15 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-http@2.5.8':
|
||||||
|
resolution: {integrity: sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-os@2.3.2':
|
||||||
|
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-shell@2.3.5':
|
||||||
|
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -732,6 +756,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
|
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a:
|
||||||
|
resolution: {tarball: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a}
|
||||||
|
version: 0.1.0
|
||||||
|
|
||||||
|
tauri-plugin-drpc@1.0.3:
|
||||||
|
resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: ^5.0.0
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
@@ -1026,6 +1059,18 @@ snapshots:
|
|||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-http@2.5.8':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-os@2.3.2':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-shell@2.3.5':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/pug@2.0.10': {}
|
'@types/pug@2.0.10': {}
|
||||||
@@ -1344,6 +1389,14 @@ snapshots:
|
|||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
zimmerframe: 1.1.4
|
zimmerframe: 1.1.4
|
||||||
|
|
||||||
|
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a:
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
|
tauri-plugin-drpc@1.0.3(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.4.0"
|
version = "0.7.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -15,17 +15,25 @@ path = "src/main.rs"
|
|||||||
tauri-build = { version = "2.0", features = [] }
|
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"
|
||||||
serde = { version = "1", features = ["derive"] }
|
tauri-plugin-updater = "2"
|
||||||
serde_json = "1"
|
tauri-plugin-process = "2"
|
||||||
walkdir = "2"
|
tauri-plugin-http = "2"
|
||||||
sysinfo = "0.32"
|
tauri-plugin-os = "2.3.2"
|
||||||
dirs = "5"
|
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
walkdir = "2"
|
||||||
|
sysinfo = "0.32"
|
||||||
|
dirs = "5"
|
||||||
|
urlencoding = "2"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = true
|
lto = true
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Moku — Suwayomi launcher sidecar for macOS.
|
||||||
|
# Tauri calls this script directly as a sidecar (Contents/MacOS/suwayomi-server-{arch}).
|
||||||
|
# The Suwayomi bundle is placed by Tauri into Contents/Resources/suwayomi-bundle/.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Resolve the real directory of this script, following symlinks.
|
||||||
|
SELF="$0"
|
||||||
|
while [ -L "$SELF" ]; do
|
||||||
|
SELF="$(readlink "$SELF")"
|
||||||
|
done
|
||||||
|
DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||||
|
|
||||||
|
# ── Locate the bundle ─────────────────────────────────────────────────────────
|
||||||
|
# Inside .app: sidecar = Contents/MacOS/suwayomi-server-{arch}
|
||||||
|
# bundle = Contents/Resources/suwayomi-bundle/
|
||||||
|
# Dev / flat layout: bundle sits next to the sidecar, or one level up.
|
||||||
|
find_bundle() {
|
||||||
|
local base="$1"
|
||||||
|
for candidate in \
|
||||||
|
"${base}/../Resources/suwayomi-bundle" \
|
||||||
|
"${base}/suwayomi-bundle" \
|
||||||
|
"${base}/../suwayomi-bundle"
|
||||||
|
do
|
||||||
|
# The jar lives at <bundle>/bin/Suwayomi-Server.jar
|
||||||
|
if [ -f "${candidate}/bin/Suwayomi-Server.jar" ]; then
|
||||||
|
# Canonicalise (no readlink -f on older macOS sh, use cd trick)
|
||||||
|
echo "$(cd "$candidate" && pwd)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
BUNDLE=$(find_bundle "$DIR") || {
|
||||||
|
echo "[sidecar] ERROR: cannot locate suwayomi-bundle relative to $DIR" >&2
|
||||||
|
echo "[sidecar] Tried:" >&2
|
||||||
|
echo " $DIR/../Resources/suwayomi-bundle" >&2
|
||||||
|
echo " $DIR/suwayomi-bundle" >&2
|
||||||
|
echo " $DIR/../suwayomi-bundle" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
JAVA="${BUNDLE}/jre/bin/java"
|
||||||
|
JAR="${BUNDLE}/bin/Suwayomi-Server.jar"
|
||||||
|
|
||||||
|
echo "[sidecar] BUNDLE=$BUNDLE" >&2
|
||||||
|
echo "[sidecar] JAVA=$JAVA" >&2
|
||||||
|
echo "[sidecar] JAR=$JAR" >&2
|
||||||
|
|
||||||
|
if [ ! -x "$JAVA" ]; then
|
||||||
|
echo "[sidecar] ERROR: java not executable at $JAVA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$JAR" ]; then
|
||||||
|
echo "[sidecar] ERROR: jar not found at $JAR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# "$@" will contain the -Dsuwayomi.tachidesk.config.server.rootDir=... flag
|
||||||
|
# prepended by spawn_server in lib.rs, followed by -jar <path>.
|
||||||
|
# We call java directly so all JVM flags reach it properly.
|
||||||
|
exec "$JAVA" \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
"$@" \
|
||||||
|
-jar "$JAR"
|
||||||
@@ -25,6 +25,19 @@
|
|||||||
"core:window:allow-outer-size",
|
"core:window:allow-outer-size",
|
||||||
"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:allow-restart",
|
||||||
|
"http:default",
|
||||||
|
"http:allow-fetch",
|
||||||
|
"discord-rpc:default",
|
||||||
|
"discord-rpc:allow-connect",
|
||||||
|
"discord-rpc:allow-disconnect",
|
||||||
|
"discord-rpc:allow-set-activity",
|
||||||
|
"discord-rpc:allow-clear-activity",
|
||||||
|
"discord-rpc:allow-is-running"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "http-scope",
|
||||||
|
"description": "HTTP fetch scope",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{ "url": "http://*:*/*" },
|
||||||
|
{ "url": "https://*:*/*" },
|
||||||
|
{ "url": "http://*/*" },
|
||||||
|
{ "url": "https://*/*" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ use std::io::Write;
|
|||||||
use sysinfo::Disks;
|
use sysinfo::Disks;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{Manager, WindowEvent};
|
use tauri::{Manager, WindowEvent};
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use tauri::Emitter;
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -24,8 +26,22 @@ pub enum SpawnError {
|
|||||||
SpawnFailed(String),
|
SpawnFailed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip the \\?\ extended-length path prefix that Windows adds to long paths.
|
#[derive(Serialize, Clone)]
|
||||||
/// Java and many other tools do not accept this prefix and will fail silently.
|
pub struct ReleaseInfo {
|
||||||
|
pub tag_name: String,
|
||||||
|
pub name: String,
|
||||||
|
pub body: String,
|
||||||
|
pub published_at: String,
|
||||||
|
pub html_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
|
struct UpdateProgress {
|
||||||
|
downloaded: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
fn strip_unc(path: PathBuf) -> PathBuf {
|
||||||
let s = path.to_string_lossy();
|
let s = path.to_string_lossy();
|
||||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||||
@@ -42,7 +58,7 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
|||||||
let base = std::env::var("XDG_DATA_HOME")
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
||||||
base.join("Tachidesk/downloads")
|
base.join("Tachidesk").join("downloads")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -83,13 +99,67 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_platform_ui_scale() -> f64 {
|
fn get_default_downloads_path() -> String {
|
||||||
#[cfg(target_os = "windows")]
|
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||||
return 1.0;
|
}
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
return 1.0;
|
#[tauri::command]
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
fn check_path_exists(path: String) -> bool {
|
||||||
return 1.5;
|
std::path::Path::new(path.trim()).is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_directory(path: String) -> Result<(), String> {
|
||||||
|
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
|
||||||
|
use tauri::Emitter;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let src_path = std::path::PathBuf::from(src.trim());
|
||||||
|
let dst_path = std::path::PathBuf::from(dst.trim());
|
||||||
|
|
||||||
|
if !src_path.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: u64 = WalkDir::new(&src_path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
.count() as u64;
|
||||||
|
|
||||||
|
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
|
||||||
|
|
||||||
|
let mut done: u64 = 0;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
|
||||||
|
let target = dst_path.join(rel);
|
||||||
|
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||||
|
done += 1;
|
||||||
|
let _ = app.emit("migrate_progress", serde_json::json!({
|
||||||
|
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||||
|
window.scale_factor().unwrap_or(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||||
@@ -99,14 +169,30 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let _ = std::process::Command::new("taskkill")
|
{
|
||||||
.args(["/F", "/FI", "IMAGENAME eq java*"])
|
use std::os::windows::process::CommandExt;
|
||||||
.status();
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status();
|
||||||
|
|
||||||
|
for _ in 0..30 {
|
||||||
|
let still_running = std::process::Command::new("tasklist")
|
||||||
|
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !still_running { break; }
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let _ = std::process::Command::new("pkill")
|
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
|
||||||
.args(["-f", "tachidesk"])
|
|
||||||
.status();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||||
@@ -203,16 +289,14 @@ struct ServerInvocation {
|
|||||||
working_dir: Option<PathBuf>,
|
working_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java.exe");
|
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||||
|
|
||||||
do_log(log, &format!("[find_java] checking path: {:?}", java));
|
do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
|
||||||
do_log(log, &format!("[find_java] exists: {}", java.exists()));
|
|
||||||
|
|
||||||
if java.exists() { Some(java) } else { None }
|
if java.exists() { Some(java) } else { None }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,28 +312,27 @@ fn resolve_server_binary(
|
|||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
log: &mut Option<std::fs::File>,
|
log: &mut Option<std::fs::File>,
|
||||||
) -> Result<ServerInvocation, SpawnError> {
|
) -> Result<ServerInvocation, SpawnError> {
|
||||||
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
do_log(log, &format!("[resolve] binary = {:?}", binary));
|
||||||
|
|
||||||
if !binary.trim().is_empty() {
|
if !binary.trim().is_empty() {
|
||||||
do_log(log, "[resolve] using user-supplied binary path");
|
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||||
return Ok(ServerInvocation {
|
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
||||||
bin: binary.to_string(),
|
if path.exists() {
|
||||||
args: vec![],
|
return Ok(ServerInvocation {
|
||||||
working_dir: None,
|
bin: path.to_string_lossy().into_owned(),
|
||||||
});
|
args: vec![],
|
||||||
|
working_dir: path.parent().map(|p| p.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
do_log(log, "[resolve] user path not found, falling through");
|
||||||
}
|
}
|
||||||
|
|
||||||
let resource_dir = match app.path().resource_dir() {
|
#[cfg(not(target_os = "macos"))]
|
||||||
Ok(p) => {
|
let resource_dir = {
|
||||||
let stripped = strip_unc(p);
|
let raw = app.path().resource_dir().unwrap_or_default();
|
||||||
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
|
let stripped = strip_unc(raw);
|
||||||
stripped
|
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
|
||||||
}
|
stripped
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("resource_dir error: {e}");
|
|
||||||
do_log(log, &format!("[resolve] ERROR: {}", msg));
|
|
||||||
return Err(SpawnError::SpawnFailed(msg));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
@@ -257,76 +340,96 @@ fn resolve_server_binary(
|
|||||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir));
|
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||||
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists()));
|
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
||||||
do_log(log, &format!("[resolve] jar = {:?}", jar));
|
|
||||||
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
|
|
||||||
|
|
||||||
match find_java_in_bundle(&bundle_dir, log) {
|
match find_java_in_bundle(&bundle_dir, log) {
|
||||||
Some(java) => {
|
Some(java) if jar.exists() => {
|
||||||
do_log(log, &format!("[resolve] java found: {:?}", java));
|
do_log(log, "[resolve] using bundled JRE");
|
||||||
if jar.exists() {
|
return Ok(ServerInvocation {
|
||||||
do_log(log, "[resolve] both java and jar found — using bundled JRE");
|
bin: java.to_string_lossy().into_owned(),
|
||||||
return Ok(ServerInvocation {
|
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||||
bin: java.to_string_lossy().into_owned(),
|
working_dir: Some(bundle_dir),
|
||||||
args: vec![
|
});
|
||||||
"-jar".to_string(),
|
|
||||||
jar.to_string_lossy().into_owned(),
|
|
||||||
],
|
|
||||||
working_dir: Some(bundle_dir),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => {
|
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
||||||
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path");
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
|
||||||
|
let p = resource_dir.join(name);
|
||||||
|
do_log(log, &format!("[resolve] sidecar: {:?} exists={}", p, p.exists()));
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: vec![],
|
||||||
|
working_dir: Some(resource_dir.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||||
|
let jar = std::fs::read_dir(&resource_dir)
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut rd| {
|
||||||
|
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
||||||
|
.and_then(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(jar_path) = jar {
|
||||||
|
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||||
|
working_dir: Some(resource_dir),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
|
let macos_dir = resource_dir.parent().map(|p| p.join("MacOS")).unwrap_or_default();
|
||||||
|
|
||||||
let candidates = [
|
let candidates = [
|
||||||
|
"suwayomi-server",
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
"suwayomi-server",
|
"suwayomi-launcher",
|
||||||
|
"suwayomi-launcher.sh",
|
||||||
|
"tachidesk-server",
|
||||||
];
|
];
|
||||||
for name in &candidates {
|
|
||||||
let p = resource_dir.join(name);
|
for search_dir in &[&macos_dir, &resource_dir] {
|
||||||
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
for name in &candidates {
|
||||||
if p.exists() {
|
let p = search_dir.join(name);
|
||||||
do_log(log, &format!("[resolve] using macOS candidate: {:?}", p));
|
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
||||||
return Ok(ServerInvocation {
|
if p.exists() {
|
||||||
bin: p.to_string_lossy().into_owned(),
|
return Ok(ServerInvocation {
|
||||||
args: vec![],
|
bin: p.to_string_lossy().into_owned(),
|
||||||
working_dir: None,
|
args: vec![],
|
||||||
});
|
working_dir: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do_log(log, "[resolve] trying PATH fallback");
|
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||||
let found = std::process::Command::new("which")
|
#[cfg(target_os = "windows")]
|
||||||
.arg(name)
|
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||||
.output()
|
#[cfg(not(target_os = "windows"))]
|
||||||
.map(|o| o.status.success())
|
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] PATH check {:?}: found={}", name, found));
|
|
||||||
|
|
||||||
if found {
|
if found {
|
||||||
do_log(log, &format!("[resolve] using PATH binary: {}", name));
|
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: name.to_string(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: None,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do_log(log, "[resolve] FAILED — no binary found anywhere");
|
|
||||||
Err(SpawnError::NotConfigured(
|
Err(SpawnError::NotConfigured(
|
||||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||||
))
|
))
|
||||||
@@ -342,50 +445,28 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
|
|||||||
}
|
}
|
||||||
|
|
||||||
let data_dir = suwayomi_data_dir();
|
let data_dir = suwayomi_data_dir();
|
||||||
|
|
||||||
let log_path = data_dir.join("moku-spawn.log");
|
let log_path = data_dir.join("moku-spawn.log");
|
||||||
let _ = std::fs::create_dir_all(&data_dir);
|
let _ = std::fs::create_dir_all(&data_dir);
|
||||||
let mut log = std::fs::OpenOptions::new()
|
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(&log_path)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
do_log(&mut log, "");
|
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
|
||||||
do_log(&mut log, "========================================");
|
|
||||||
do_log(&mut log, &format!("[spawn_server] called at {:?}", std::time::SystemTime::now()));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] binary arg = {:?}", binary));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] data_dir = {:?}", data_dir));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] log file = {:?}", log_path));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] APPDATA = {:?}", std::env::var("APPDATA")));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] LOCALAPPDATA = {:?}", std::env::var("LOCALAPPDATA")));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] current_dir = {:?}", std::env::current_dir()));
|
|
||||||
|
|
||||||
seed_server_conf(&data_dir);
|
seed_server_conf(&data_dir);
|
||||||
do_log(&mut log, "[spawn_server] server.conf seeded");
|
|
||||||
|
|
||||||
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) {
|
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||||
Ok(i) => i,
|
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
||||||
Err(e) => {
|
e
|
||||||
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e));
|
})?;
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let bin_display = invocation.bin.clone();
|
|
||||||
let rootdir_flag = format!(
|
let rootdir_flag = format!(
|
||||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||||
data_dir.to_string_lossy()
|
data_dir.to_string_lossy()
|
||||||
);
|
);
|
||||||
|
|
||||||
invocation.args.insert(0, rootdir_flag);
|
invocation.args.insert(0, rootdir_flag);
|
||||||
|
|
||||||
let working_dir = invocation.working_dir
|
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
|
||||||
|
|
||||||
do_log(&mut log, &format!("[spawn_server] bin = {:?}", bin_display));
|
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
|
||||||
do_log(&mut log, &format!("[spawn_server] args = {:?}", invocation.args));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] working_dir = {:?}", working_dir));
|
|
||||||
|
|
||||||
let cmd = app.shell()
|
let cmd = app.shell()
|
||||||
.command(&invocation.bin)
|
.command(&invocation.bin)
|
||||||
@@ -393,17 +474,13 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
|
|||||||
.args(&invocation.args)
|
.args(&invocation.args)
|
||||||
.current_dir(&working_dir);
|
.current_dir(&working_dir);
|
||||||
|
|
||||||
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
|
|
||||||
|
|
||||||
match cmd.spawn() {
|
match cmd.spawn() {
|
||||||
Ok((_rx, child)) => {
|
Ok((_rx, child)) => {
|
||||||
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
|
|
||||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e));
|
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||||
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
|
|
||||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,16 +492,106 @@ fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Moku")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("GitHub API returned {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct GhRelease {
|
||||||
|
tag_name: String,
|
||||||
|
name: Option<String>,
|
||||||
|
body: Option<String>,
|
||||||
|
published_at: Option<String>,
|
||||||
|
html_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||||
|
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(releases.into_iter().map(|r| ReleaseInfo {
|
||||||
|
tag_name: r.tag_name.clone(),
|
||||||
|
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||||
|
body: r.body.unwrap_or_default(),
|
||||||
|
published_at: r.published_at.unwrap_or_default(),
|
||||||
|
html_url: r.html_url,
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
|
||||||
|
let updater = app.updater().map_err(|e| e.to_string())?;
|
||||||
|
let update = updater.check().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
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())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn restart_app(app: tauri::AppHandle) {
|
||||||
|
tauri::process::restart(&app.env());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[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()
|
||||||
|
.plugin(tauri_plugin_discord_rpc::init())
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_http::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,
|
||||||
|
get_default_downloads_path,
|
||||||
|
check_path_exists,
|
||||||
|
create_directory,
|
||||||
|
migrate_downloads,
|
||||||
spawn_server,
|
spawn_server,
|
||||||
kill_server,
|
kill_server,
|
||||||
get_platform_ui_scale,
|
get_platform_ui_scale,
|
||||||
|
list_releases,
|
||||||
|
download_and_install_update,
|
||||||
|
restart_app,
|
||||||
])
|
])
|
||||||
.setup(|_app| Ok(()))
|
.setup(|_app| Ok(()))
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.4.0",
|
"version": "0.7.1",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "dev.moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -49,6 +49,10 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
|
},
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
||||||
|
"endpoints": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"decorations": true,
|
||||||
|
"titleBarStyle": "Overlay",
|
||||||
|
"hiddenTitle": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["dmg"],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/suwayomi-server"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"binaries/suwayomi-bundle": "suwayomi-bundle"
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "11.0",
|
||||||
|
"exceptionDomain": "localhost",
|
||||||
|
"frameworks": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
{
|
{
|
||||||
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,33 +2,108 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { gql } from "./lib/client";
|
import { gql } from "./lib/client";
|
||||||
|
import logoUrl from "./assets/moku-icon-splash.svg";
|
||||||
|
import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
|
||||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||||
import { store, addToast, setActiveDownloads } from "./store/state.svelte";
|
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
||||||
|
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||||
import Layout from "./components/layout/Layout.svelte";
|
import Layout from "./components/chrome/Layout.svelte";
|
||||||
import Reader from "./components/reader/Reader.svelte";
|
import Reader from "./components/reader/Reader.svelte";
|
||||||
import Settings from "./components/settings/Settings.svelte";
|
import Settings from "./components/settings/Settings.svelte";
|
||||||
import TitleBar from "./components/layout/TitleBar.svelte";
|
import ThemeEditor from "./components/settings/ThemeEditor.svelte";
|
||||||
import Toaster from "./components/layout/Toaster.svelte";
|
import TitleBar from "./components/chrome/TitleBar.svelte";
|
||||||
import SplashScreen from "./components/layout/SplashScreen.svelte";
|
import Toaster from "./components/chrome/Toaster.svelte";
|
||||||
|
import SplashScreen from "./components/chrome/SplashScreen.svelte";
|
||||||
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 60;
|
let themeStyleEl: HTMLStyleElement | null = null;
|
||||||
|
|
||||||
let serverProbeOk = $state(!store.settings.autoStartServer);
|
$effect(() => {
|
||||||
let appReady = $state(!store.settings.autoStartServer);
|
const themeId = store.settings.theme ?? "dark";
|
||||||
let failed = $state(false);
|
const isCustom = themeId.startsWith("custom:");
|
||||||
let notConfigured = $state(false);
|
|
||||||
let idle = $state(false);
|
if (!isCustom) {
|
||||||
let devSplash = $state(false);
|
themeStyleEl?.remove();
|
||||||
let platformScale = $state(1);
|
themeStyleEl = null;
|
||||||
|
document.documentElement.setAttribute("data-theme", themeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const custom = store.settings.customThemes?.find(t => t.id === themeId);
|
||||||
|
if (!custom) {
|
||||||
|
themeStyleEl?.remove();
|
||||||
|
themeStyleEl = null;
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vars = Object.entries(custom.tokens)
|
||||||
|
.map(([k, v]) => ` --${k}: ${v};`)
|
||||||
|
.join("\n");
|
||||||
|
const css = `[data-theme="custom"] {\n${vars}\n}`;
|
||||||
|
|
||||||
|
if (!themeStyleEl) {
|
||||||
|
themeStyleEl = document.createElement("style");
|
||||||
|
themeStyleEl.id = "moku-custom-theme";
|
||||||
|
document.head.appendChild(themeStyleEl);
|
||||||
|
}
|
||||||
|
themeStyleEl.textContent = css;
|
||||||
|
document.documentElement.setAttribute("data-theme", "custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
let themeEditorOpen = $state(false);
|
||||||
|
let themeEditorEditId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function openThemeEditor(id?: string | null) {
|
||||||
|
themeEditorEditId = id ?? null;
|
||||||
|
themeEditorOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeThemeEditor() {
|
||||||
|
themeEditorOpen = false;
|
||||||
|
themeEditorEditId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 10;
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
|
||||||
|
let serverProbeOk = $state(false);
|
||||||
|
let appReady = $state(false);
|
||||||
|
let failed = $state(false);
|
||||||
|
let notConfigured = $state(false);
|
||||||
|
let idle = $state(false);
|
||||||
|
let devSplash = $state(false);
|
||||||
|
|
||||||
|
let loginRequired = $state(false);
|
||||||
|
let loginUser = $state(store.settings.serverAuthUser ?? "");
|
||||||
|
let loginPass = $state("");
|
||||||
|
let loginError = $state<string | null>(null);
|
||||||
|
let loginBusy = $state(false);
|
||||||
|
let unsupportedMode = $state(false);
|
||||||
|
|
||||||
|
let platformScale = $state(1.0);
|
||||||
|
let _appliedZoom = -1;
|
||||||
|
let _vhRafId: number | null = null;
|
||||||
|
|
||||||
function applyZoom() {
|
function applyZoom() {
|
||||||
const normalized = store.settings.uiScale * platformScale;
|
const uiZoom = store.settings.uiZoom ?? 1.0;
|
||||||
document.documentElement.style.zoom = `${normalized}%`;
|
if (uiZoom === _appliedZoom) return;
|
||||||
document.documentElement.style.setProperty("--ui-scale", String(normalized));
|
_appliedZoom = uiZoom;
|
||||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`);
|
|
||||||
|
const pct = uiZoom * 100;
|
||||||
|
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
|
||||||
|
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
|
||||||
|
document.documentElement.style.zoom = `${pct}%`;
|
||||||
|
|
||||||
|
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
|
||||||
|
_vhRafId = requestAnimationFrame(() => {
|
||||||
|
_vhRafId = null;
|
||||||
|
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let prevQueue: DownloadQueueItem[] = [];
|
let prevQueue: DownloadQueueItem[] = [];
|
||||||
@@ -57,8 +132,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetIdle() {
|
function resetIdle() {
|
||||||
if (idle) return;
|
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
if (idle) return;
|
||||||
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||||
if (ms === 0) return;
|
if (ms === 0) return;
|
||||||
idleTimer = setTimeout(() => idle = true, ms);
|
idleTimer = setTimeout(() => idle = true, ms);
|
||||||
@@ -74,15 +149,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Re-runs whenever uiScale or platformScale changes.
|
void store.settings.uiZoom;
|
||||||
store.settings.uiScale; platformScale;
|
|
||||||
applyZoom();
|
applyZoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
document.documentElement.setAttribute("data-theme", store.settings.theme ?? "dark");
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!appReady) return;
|
if (!appReady) return;
|
||||||
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
@@ -92,14 +162,109 @@
|
|||||||
return () => clearInterval(pollInterval);
|
return () => clearInterval(pollInterval);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function checkForUpdateSilently() {
|
||||||
|
try {
|
||||||
|
const [currentVersion, releases] = await Promise.all([
|
||||||
|
getVersion(),
|
||||||
|
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
||||||
|
if (!valid.length) return;
|
||||||
|
|
||||||
|
const parse = (tag: string): number[] =>
|
||||||
|
tag.replace(/^v/, "").split(".").map(Number);
|
||||||
|
|
||||||
|
const compare = (a: number[], b: number[]): number => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const latestTag = valid
|
||||||
|
.map(r => r.tag_name)
|
||||||
|
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
||||||
|
.replace(/^v/, "");
|
||||||
|
|
||||||
|
const isNewer = compare(parse(latestTag), parse(currentVersion)) < 0;
|
||||||
|
if (isNewer) {
|
||||||
|
addToast({
|
||||||
|
kind: "info",
|
||||||
|
title: `Update available — v${latestTag}`,
|
||||||
|
body: "Open Settings → About to install.",
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelProbe = false;
|
||||||
|
|
||||||
|
function startProbe() {
|
||||||
|
cancelProbe = false;
|
||||||
|
failed = false;
|
||||||
|
loginRequired = false;
|
||||||
|
let tries = 0;
|
||||||
|
|
||||||
|
async function probe() {
|
||||||
|
if (cancelProbe) return;
|
||||||
|
tries++;
|
||||||
|
const result = await probeServer();
|
||||||
|
if (cancelProbe) return;
|
||||||
|
|
||||||
|
if (result === "ok") {
|
||||||
|
serverProbeOk = true;
|
||||||
|
loginRequired = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === "auth_required") {
|
||||||
|
serverProbeOk = true;
|
||||||
|
const savedUser = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const savedPass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
if (savedUser && savedPass) {
|
||||||
|
try {
|
||||||
|
await loginBasic(savedUser, savedPass);
|
||||||
|
loginRequired = false;
|
||||||
|
return;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
loginRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === "unsupported_mode") {
|
||||||
|
serverProbeOk = true;
|
||||||
|
unsupportedMode = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
|
||||||
|
setTimeout(probe, 750);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(probe, 800);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
(window as any).__mokuShowSplash = () => devSplash = true;
|
||||||
|
|
||||||
// Fetch the platform scale factor then immediately re-apply zoom.
|
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
|
||||||
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1);
|
|
||||||
applyZoom();
|
applyZoom();
|
||||||
|
|
||||||
|
store.isFullscreen = await win.isFullscreen();
|
||||||
|
|
||||||
|
const unlistenResize = await win.onResized(async () => {
|
||||||
|
store.isFullscreen = await win.isFullscreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenScale = await win.onScaleChanged(async (event) => {
|
||||||
|
platformScale = event.payload.scaleFactor;
|
||||||
|
applyZoom();
|
||||||
|
});
|
||||||
|
|
||||||
if (store.settings.autoStartServer) {
|
if (store.settings.autoStartServer) {
|
||||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||||
if (err?.kind === "NotConfigured") {
|
if (err?.kind === "NotConfigured") {
|
||||||
@@ -110,30 +275,16 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverProbeOk) {
|
startProbe();
|
||||||
let cancelled = false, tries = 0;
|
|
||||||
async function probe() {
|
|
||||||
if (cancelled) return;
|
|
||||||
tries++;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${store.settings.serverUrl}/api/graphql`, {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
});
|
|
||||||
if (res.ok && !cancelled) { serverProbeOk = true; return; }
|
|
||||||
} catch {}
|
|
||||||
if (tries >= MAX_ATTEMPTS && !cancelled) { failed = true; return; }
|
|
||||||
if (!cancelled) setTimeout(probe, 500);
|
|
||||||
}
|
|
||||||
setTimeout(probe, 800);
|
|
||||||
}
|
|
||||||
|
|
||||||
type P = { chapterId: number; mangaId: number; progress: number }[];
|
type P = { chapterId: number; mangaId: number; progress: number }[];
|
||||||
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
|
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelProbe = true;
|
||||||
|
unlistenResize();
|
||||||
|
unlistenScale();
|
||||||
|
destroyRpc();
|
||||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
@@ -142,28 +293,156 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleRetry() { failed = false; notConfigured = false; serverProbeOk = false; }
|
$effect(() => {
|
||||||
|
if (!appReady) return;
|
||||||
|
const timer = setTimeout(checkForUpdateSilently, 5_000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (store.settings.discordRpc) {
|
||||||
|
initRpc();
|
||||||
|
} else {
|
||||||
|
clearReading();
|
||||||
|
destroyRpc();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!store.activeChapter) {
|
||||||
|
if (store.settings.discordRpc) setIdle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleZoomKey(e: KeyboardEvent) {
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
if (e.key === "=" || e.key === "+") {
|
||||||
|
e.preventDefault();
|
||||||
|
store.settings.uiZoom = Math.min(2.0, Math.round(((store.settings.uiZoom ?? 1.0) + 0.1) * 10) / 10);
|
||||||
|
} else if (e.key === "-") {
|
||||||
|
e.preventDefault();
|
||||||
|
store.settings.uiZoom = Math.max(0.5, Math.round(((store.settings.uiZoom ?? 1.0) - 0.1) * 10) / 10);
|
||||||
|
} else if (e.key === "0") {
|
||||||
|
e.preventDefault();
|
||||||
|
store.settings.uiZoom = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
window.addEventListener("keydown", handleZoomKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleZoomKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!loginUser.trim() || !loginPass.trim()) {
|
||||||
|
loginError = "Username and password are required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loginBusy = true;
|
||||||
|
loginError = null;
|
||||||
|
try {
|
||||||
|
await loginBasic(loginUser.trim(), loginPass.trim());
|
||||||
|
loginRequired = false;
|
||||||
|
loginPass = "";
|
||||||
|
loginError = null;
|
||||||
|
appReady = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
loginError = e?.message ?? "Login failed";
|
||||||
|
} finally {
|
||||||
|
loginBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRetry() {
|
||||||
|
failed = false;
|
||||||
|
notConfigured = false;
|
||||||
|
serverProbeOk = false;
|
||||||
|
loginRequired = false;
|
||||||
|
unsupportedMode = false;
|
||||||
|
startProbe();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBypass() {
|
||||||
|
cancelProbe = true;
|
||||||
|
serverProbeOk = true;
|
||||||
|
loginRequired = false;
|
||||||
|
unsupportedMode = false;
|
||||||
|
appReady = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if devSplash}
|
{#if devSplash}
|
||||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||||
{:else if !appReady}
|
{:else if !appReady && !loginRequired && !unsupportedMode}
|
||||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
||||||
showCards={store.settings.splashCards ?? true}
|
showCards={store.settings.splashCards ?? true}
|
||||||
onReady={() => appReady = true}
|
onReady={() => { appReady = true; }}
|
||||||
onRetry={handleRetry} />
|
onRetry={handleRetry}
|
||||||
|
onBypass={handleBypass} />
|
||||||
|
{:else if unsupportedMode}
|
||||||
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
|
<div class="auth-overlay">
|
||||||
|
<div class="auth-card">
|
||||||
|
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||||
|
<p class="auth-title">moku</p>
|
||||||
|
<span class="auth-mode-badge auth-mode-badge--warn">{
|
||||||
|
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||||
|
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth"
|
||||||
|
}</span>
|
||||||
|
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||||
|
<p class="auth-body">
|
||||||
|
<strong>{
|
||||||
|
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||||
|
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode"
|
||||||
|
}</strong> is not supported. Switch your server to <strong>Basic Auth</strong> and update Settings → Security.
|
||||||
|
</p>
|
||||||
|
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Continue anyway</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if loginRequired}
|
||||||
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
|
<div class="auth-overlay">
|
||||||
|
<div class="auth-card">
|
||||||
|
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||||
|
<p class="auth-title">moku</p>
|
||||||
|
<span class="auth-mode-badge">Basic Auth</span>
|
||||||
|
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||||
|
{#if loginError}
|
||||||
|
<p class="auth-error">{loginError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="auth-fields">
|
||||||
|
<input class="auth-input" type="text" placeholder="Username"
|
||||||
|
bind:value={loginUser} disabled={loginBusy} autocomplete="username"
|
||||||
|
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||||
|
<input class="auth-input" type="password" placeholder="Password"
|
||||||
|
bind:value={loginPass} disabled={loginBusy} autocomplete="current-password"
|
||||||
|
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||||
|
</div>
|
||||||
|
<button class="auth-btn" onclick={handleLogin}
|
||||||
|
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}>
|
||||||
|
{loginBusy ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Skip</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="root">
|
<div id="app-shell" class="root">
|
||||||
{#if idle && !store.activeChapter}
|
{#if idle && !store.activeChapter}
|
||||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => setTimeout(() => idle = false, 340)} />
|
onDismiss={() => { idle = false; resetIdle(); }} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if !store.activeChapter}<TitleBar />{/if}
|
{#if !store.activeChapter}<TitleBar />{/if}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if store.settingsOpen}<Settings />{/if}
|
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
||||||
|
{#if themeEditorOpen}
|
||||||
|
<ThemeEditor
|
||||||
|
bind:editingId={themeEditorEditId}
|
||||||
|
onClose={closeThemeEditor}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<MangaPreview />
|
<MangaPreview />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
@@ -172,4 +451,27 @@
|
|||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.content { flex: 1; overflow: hidden; }
|
.content { flex: 1; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Auth overlay — floats above the SplashScreen */
|
||||||
|
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||||
|
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; }
|
||||||
|
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
|
||||||
|
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
|
||||||
|
.auth-mode-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-full); padding: 2px 10px; }
|
||||||
|
.auth-mode-badge--warn { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
||||||
|
.auth-host { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: -4px 0 0; }
|
||||||
|
.auth-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); margin: 0; }
|
||||||
|
.auth-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
|
.auth-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin: 0; width: 100%; box-sizing: border-box; }
|
||||||
|
.auth-fields { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; }
|
||||||
|
.auth-input { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); outline: none; box-sizing: border-box; transition: border-color var(--t-base), box-shadow var(--t-base); font-family: inherit; }
|
||||||
|
.auth-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
|
.auth-input:disabled { opacity: 0.5; }
|
||||||
|
.auth-btn { width: 100%; padding: 9px; border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-sm); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); }
|
||||||
|
.auth-btn:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.auth-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.auth-btn--ghost { background: none; border-color: transparent; color: var(--text-faint); font-size: var(--text-xs); padding: 4px; }
|
||||||
|
.auth-btn--ghost:hover:not(:disabled) { color: var(--text-muted); opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
import Sidebar from "./Sidebar.svelte";
|
import Sidebar from "./Sidebar.svelte";
|
||||||
import Home from "../pages/Home.svelte";
|
import Home from "../pages/Home.svelte";
|
||||||
import Library from "../pages/Library.svelte";
|
import Library from "../pages/Library.svelte";
|
||||||
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
import SeriesDetail from "../series/SeriesDetail.svelte";
|
||||||
import History from "../pages/History.svelte";
|
import RecentActivity from "./RecentActivity.svelte";
|
||||||
import Search from "../pages/Search.svelte";
|
import Search from "../pages/Search.svelte";
|
||||||
import Discover from "../pages/Discover.svelte";
|
import Discover from "../pages/Discover.svelte";
|
||||||
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
|
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
|
||||||
import Downloads from "../pages/Downloads.svelte";
|
import Downloads from "../pages/Downloads.svelte";
|
||||||
import Extensions from "../pages/Extensions.svelte";
|
import Extensions from "../pages/Extensions.svelte";
|
||||||
|
import Tracking from "../pages/Tracking.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
{:else if store.navPage === "search"}
|
{:else if store.navPage === "search"}
|
||||||
<Search />
|
<Search />
|
||||||
{:else if store.navPage === "history"}
|
{:else if store.navPage === "history"}
|
||||||
<History />
|
<RecentActivity />
|
||||||
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
|
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
|
||||||
<GenreDrillPage />
|
<GenreDrillPage />
|
||||||
{:else if store.navPage === "explore" || store.navPage === "sources"}
|
{:else if store.navPage === "explore" || store.navPage === "sources"}
|
||||||
@@ -33,6 +34,8 @@
|
|||||||
<Downloads />
|
<Downloads />
|
||||||
{:else if store.navPage === "extensions"}
|
{:else if store.navPage === "extensions"}
|
||||||
<Extensions />
|
<Extensions />
|
||||||
|
{:else if store.navPage === "tracking"}
|
||||||
|
<Tracking />
|
||||||
{:else}
|
{:else}
|
||||||
<Home />
|
<Home />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
||||||
import { thumbUrl } from "../../lib/client";
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
|
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
import type { HistoryEntry } from "../../store/state.svelte";
|
||||||
|
|
||||||
@@ -79,11 +79,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filtered = $derived(search.trim()
|
const filtered = $derived(search.trim()
|
||||||
? store..filter((e) =>
|
? store.history.filter((e) =>
|
||||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
e.chapterName.toLowerCase().includes(search.toLowerCase())
|
e.chapterName.toLowerCase().includes(search.toLowerCase())
|
||||||
)
|
)
|
||||||
: store.);
|
: store.history);
|
||||||
|
|
||||||
const sessions = $derived(buildSessions(filtered));
|
const sessions = $derived(buildSessions(filtered));
|
||||||
|
|
||||||
@@ -97,10 +97,16 @@
|
|||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resume: navigate to the manga's SeriesDetail (which will pick up from
|
||||||
|
// activeChapterList once chapters load). We can't hold a stale chapter list
|
||||||
|
// here — SeriesDetail fetches fresh chapters itself.
|
||||||
function resume(session: Session) {
|
function resume(session: Session) {
|
||||||
const ch = store..find((c) => c.id === session.latestChapterId);
|
setActiveManga({
|
||||||
if (ch && store..length > 0) openReader(ch, );
|
id: session.mangaId,
|
||||||
else setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
title: session.mangaTitle,
|
||||||
|
thumbnailUrl: session.thumbnailUrl,
|
||||||
|
inLibrary: false,
|
||||||
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClear() {
|
function handleClear() {
|
||||||
@@ -111,17 +117,17 @@
|
|||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="header">
|
||||||
<span class="heading">History</span>
|
<span class="heading">History</span>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
<input class="search" placeholder="Search store.…" bind:value={search} />
|
<input class="search" placeholder="Search history…" bind:value={search} />
|
||||||
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
|
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if store..length > 0}
|
{#if store.history.length > 0}
|
||||||
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
|
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
|
||||||
title={confirmClear ? "Click again to confirm" : "Clear store. feed"}>
|
title={confirmClear ? "Click again to confirm" : "Clear history"}>
|
||||||
<Trash size={14} weight="light" />
|
<Trash size={14} weight="light" />
|
||||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -129,44 +135,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if store..totalChaptersRead > 0}
|
{#if store.readingStats.totalChaptersRead > 0}
|
||||||
<div class="stats-bar">
|
<div class="stats-bar">
|
||||||
<div class="stat-group">
|
<div class="stat-group">
|
||||||
<Fire size={13} weight="fill" class="stat-fire" />
|
<Fire size={13} weight="fill" class="stat-fire" />
|
||||||
<span class="stat-val accent">{store..currentStreakDays}</span>
|
<span class="stat-val accent">{store.readingStats.currentStreakDays}</span>
|
||||||
<span class="stat-label">day streak</span>
|
<span class="stat-label">day streak</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-sep"></div>
|
<div class="stat-sep"></div>
|
||||||
<div class="stat-group">
|
<div class="stat-group">
|
||||||
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
|
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
|
||||||
<span class="stat-val">{store..totalChaptersRead}</span>
|
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
|
||||||
<span class="stat-label">chapters</span>
|
<span class="stat-label">chapters</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-sep"></div>
|
<div class="stat-sep"></div>
|
||||||
<div class="stat-group">
|
<div class="stat-group">
|
||||||
<Clock size={13} weight="light" class="stat-icon-neutral" />
|
<Clock size={13} weight="light" class="stat-icon-neutral" />
|
||||||
<span class="stat-val">{formatReadTime(store..totalMinutesRead)}</span>
|
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
|
||||||
<span class="stat-label">read time</span>
|
<span class="stat-label">read time</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-sep"></div>
|
<div class="stat-sep"></div>
|
||||||
<div class="stat-group">
|
<div class="stat-group">
|
||||||
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
|
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
|
||||||
<span class="stat-val">{store..totalMangaRead}</span>
|
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
|
||||||
<span class="stat-label">series</span>
|
<span class="stat-label">series</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-sep"></div>
|
<div class="stat-sep"></div>
|
||||||
<div class="stat-group">
|
<div class="stat-group">
|
||||||
<span class="stat-val muted">{store..longestStreakDays}d</span>
|
<span class="stat-val muted">{store.readingStats.longestStreakDays}d</span>
|
||||||
<span class="stat-label">best streak</span>
|
<span class="stat-label">best streak</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="stats-note">Stats are preserved when you clear the feed</span>
|
<span class="stats-note">Stats are preserved when you clear the feed</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if store..length === 0}
|
{#if store.history.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
||||||
<p class="empty-text">No reading store.</p>
|
<p class="empty-text">No reading history yet</p>
|
||||||
<p class="empty-hint">Chapters you read will appear here</p>
|
<p class="empty-hint">Chapters you read will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if sessions.length === 0}
|
{:else if sessions.length === 0}
|
||||||
@@ -186,7 +192,7 @@
|
|||||||
{#each items as session (session.latestChapterId)}
|
{#each items as session (session.latestChapterId)}
|
||||||
<button class="session-row" onclick={() => resume(session)}>
|
<button class="session-row" onclick={() => resume(session)}>
|
||||||
<div class="thumb-wrap">
|
<div class="thumb-wrap">
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
|
<Thumbnail src={session.thumbnailUrl} alt={session.mangaTitle} class="thumb" />
|
||||||
{#if session.chapterCount > 1}
|
{#if session.chapterCount > 1}
|
||||||
<span class="session-count">{session.chapterCount}</span>
|
<span class="session-count">{session.chapterCount}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -223,16 +229,16 @@
|
|||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
|
||||||
.page-header {
|
.header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
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;
|
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); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
.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-right { display: flex; align-items: center; gap: var(--sp-2); }
|
.header-right { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 26px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; 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 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; 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); }
|
||||||
.search-clear { position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
.search-clear { position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||||
@@ -284,7 +290,7 @@
|
|||||||
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
|
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
|
||||||
|
|
||||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
.thumb-wrap { position: relative; flex-shrink: 0; }
|
||||||
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
:global(.thumb) { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||||
.session-count {
|
.session-count {
|
||||||
position: absolute; bottom: -4px; right: -6px;
|
position: absolute; bottom: -4px; right: -6px;
|
||||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix } from "phosphor-svelte";
|
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
|
||||||
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
||||||
import type { NavPage } from "../../store/state.svelte";
|
import type { NavPage } from "../../store/state.svelte";
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
{ id: "explore", label: "Discover", icon: Compass },
|
{ id: "explore", label: "Discover", icon: Compass },
|
||||||
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
||||||
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
||||||
|
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
|
||||||
];
|
];
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
function navigate(id: NavPage) {
|
||||||
@@ -49,19 +50,20 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; }
|
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; }
|
||||||
.logo { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
|
.logo { width: 80px; height: 80px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||||
.logo:active { transform: scale(0.92); }
|
.logo:active { transform: scale(0.92); }
|
||||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
|
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
|
||||||
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); }
|
.nav { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
|
||||||
.tab { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
|
.nav::-webkit-scrollbar { display: none; }
|
||||||
|
.tab { width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
|
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.bottom { display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
.bottom { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
||||||
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
||||||
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||||
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { store } from "../../store/state.svelte";
|
||||||
|
import logoUrl from "../../assets/moku-icon-splash.svg";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode?: "loading" | "idle";
|
||||||
|
ringFull?: boolean;
|
||||||
|
failed?: boolean;
|
||||||
|
notConfigured?: boolean;
|
||||||
|
showCards?: boolean;
|
||||||
|
showFps?: boolean;
|
||||||
|
onReady?: () => void;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onBypass?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
mode = "loading", ringFull = false, failed = false,
|
||||||
|
notConfigured = false, showCards = true, showFps = false,
|
||||||
|
onReady, onRetry, onBypass, onDismiss,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const lockEnabled = $derived(
|
||||||
|
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
|
||||||
|
);
|
||||||
|
|
||||||
|
let pinEntry = $state("");
|
||||||
|
let pinShake = $state(false);
|
||||||
|
let pinUnlocked = $state(false);
|
||||||
|
let pinVisible = $state(false);
|
||||||
|
let uiScale = $state(1);
|
||||||
|
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
||||||
|
|
||||||
|
const logoLoadingSize = 140;
|
||||||
|
const logoIdleSize = 128;
|
||||||
|
const logoLockSize = 96;
|
||||||
|
|
||||||
|
const ringR = $derived(70);
|
||||||
|
const ringPad = $derived(12);
|
||||||
|
const ringSize = $derived((ringR + ringPad) * 2);
|
||||||
|
const ringC = $derived(ringR + ringPad);
|
||||||
|
const ringCirc = $derived(2 * Math.PI * ringR);
|
||||||
|
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
|
||||||
|
const ringTop = $derived(-((ringSize - logoLoadingSize) / 2));
|
||||||
|
const ringLeft = $derived(-((ringSize - logoLoadingSize) / 2));
|
||||||
|
|
||||||
|
function submitPin() {
|
||||||
|
if (pinEntry === store.settings.appLockPin) {
|
||||||
|
pinUnlocked = true;
|
||||||
|
pinEntry = "";
|
||||||
|
if (mode === "idle") triggerExit(onDismiss);
|
||||||
|
} else {
|
||||||
|
pinShake = true;
|
||||||
|
pinEntry = "";
|
||||||
|
setTimeout(() => (pinShake = false), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPinKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter") { submitPin(); return; }
|
||||||
|
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
|
||||||
|
if (/^\d$/.test(e.key)) {
|
||||||
|
pinEntry = (pinEntry + e.key).slice(0, 8);
|
||||||
|
if (pinEntry.length >= (store.settings.appLockPin?.length ?? 4)) submitPin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXIT_MS = 320;
|
||||||
|
const PHASE1_TARGET = 0.85;
|
||||||
|
const PHASE1_MS = 3000;
|
||||||
|
const PHASE2_TARGET = 0.95;
|
||||||
|
const PHASE2_MS = 10000;
|
||||||
|
|
||||||
|
let dots = $state("");
|
||||||
|
let ringProg = $state(0.025);
|
||||||
|
let exiting = $state(false);
|
||||||
|
let exitLock = false;
|
||||||
|
|
||||||
|
function triggerExit(cb?: () => void) {
|
||||||
|
if (exitLock) return;
|
||||||
|
exitLock = true;
|
||||||
|
exiting = true;
|
||||||
|
setTimeout(() => cb?.(), EXIT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
let animFrame: number;
|
||||||
|
let animStart: number | null = null;
|
||||||
|
let animPhase = 1;
|
||||||
|
|
||||||
|
function animateRing(ts: number) {
|
||||||
|
if (exitLock) return;
|
||||||
|
if (animStart === null) animStart = ts;
|
||||||
|
const elapsed = ts - animStart;
|
||||||
|
if (animPhase === 1) {
|
||||||
|
const t = Math.min(elapsed / PHASE1_MS, 1);
|
||||||
|
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
|
||||||
|
if (t >= 1) { animPhase = 2; animStart = ts; }
|
||||||
|
} else {
|
||||||
|
const t = Math.min(elapsed / PHASE2_MS, 1);
|
||||||
|
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
|
||||||
|
}
|
||||||
|
animFrame = requestAnimationFrame(animateRing);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (mode === "loading" && !failed && !notConfigured) {
|
||||||
|
animFrame = requestAnimationFrame(animateRing);
|
||||||
|
return () => cancelAnimationFrame(animFrame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!ringFull) return;
|
||||||
|
cancelAnimationFrame(animFrame);
|
||||||
|
ringProg = 1;
|
||||||
|
if (lockEnabled && !pinUnlocked) {
|
||||||
|
setTimeout(() => (pinVisible = true), 400);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => triggerExit(onReady), 650);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const needsPin =
|
||||||
|
(mode === "idle" && lockEnabled) ||
|
||||||
|
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
|
||||||
|
if (!needsPin) return;
|
||||||
|
window.addEventListener("keydown", onPinKey);
|
||||||
|
return () => window.removeEventListener("keydown", onPinKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (pinUnlocked && mode !== "idle") triggerExit(onReady);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dotsInterval = setInterval(() => {
|
||||||
|
dots = dots.length >= 3 ? "" : dots + ".";
|
||||||
|
}, 420);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
uiScale = await win.scaleFactor();
|
||||||
|
|
||||||
|
if (mode === "idle" && onDismiss) {
|
||||||
|
if (lockEnabled) return () => clearInterval(dotsInterval);
|
||||||
|
const handler = () => triggerExit(onDismiss);
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
window.addEventListener("keydown", handler, { once: true });
|
||||||
|
window.addEventListener("mousedown", handler, { once: true });
|
||||||
|
window.addEventListener("touchstart", handler, { once: true });
|
||||||
|
}, 200);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
clearInterval(dotsInterval);
|
||||||
|
window.removeEventListener("keydown", handler);
|
||||||
|
window.removeEventListener("mousedown", handler);
|
||||||
|
window.removeEventListener("touchstart", handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => clearInterval(dotsInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
||||||
|
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||||
|
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
|
||||||
|
|
||||||
|
const LAYER_CFG = [
|
||||||
|
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||||
|
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
||||||
|
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const BUF = 80, COLS = 14;
|
||||||
|
|
||||||
|
function hash(n: number): number {
|
||||||
|
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||||
|
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
||||||
|
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCards(vw: number, vh: number) {
|
||||||
|
const cards: CardDef[] = [];
|
||||||
|
const laneW = vw / COLS;
|
||||||
|
for (let layer = 0; layer < 3; layer++) {
|
||||||
|
const cfg = LAYER_CFG[layer];
|
||||||
|
for (let col = 0; col < COLS; col++) {
|
||||||
|
const seed = col * 31 + layer * 97 + 7;
|
||||||
|
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
||||||
|
const h = w * 1.44;
|
||||||
|
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
||||||
|
const travel = vh + h + BUF;
|
||||||
|
cards.push({
|
||||||
|
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
||||||
|
w, h,
|
||||||
|
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||||
|
alpha: cfg.alpha,
|
||||||
|
speed,
|
||||||
|
cycleSec: travel / speed,
|
||||||
|
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||||
|
travel,
|
||||||
|
yStart: vh + h / 2 + BUF / 2,
|
||||||
|
angleStart: hash(seed + 3) * 50 - 25,
|
||||||
|
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const trigs: CardTrig[] = cards.map(c => ({
|
||||||
|
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||||
|
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||||
|
tiltRad: c.tilt * (Math.PI / 180),
|
||||||
|
}));
|
||||||
|
return { cards, trigs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||||
|
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||||
|
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||||
|
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAMP_PAD = 6;
|
||||||
|
|
||||||
|
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||||
|
const oc = document.createElement("canvas");
|
||||||
|
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
|
||||||
|
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
|
||||||
|
const ctx = oc.getContext("2d")!;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
const x0 = STAMP_PAD, y0 = STAMP_PAD;
|
||||||
|
const coverH = c.w * 0.72 * 1.05;
|
||||||
|
const lineY0 = y0 + 3 + coverH + 5;
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
||||||
|
ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
||||||
|
for (let li = 0; li < c.lines; li++) {
|
||||||
|
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
||||||
|
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
||||||
|
}
|
||||||
|
return oc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||||
|
const oc = document.createElement("canvas");
|
||||||
|
oc.width = Math.round(vw * dpr);
|
||||||
|
oc.height = Math.round(vh * dpr);
|
||||||
|
const ctx = oc.getContext("2d")!;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
||||||
|
g.addColorStop(0, "rgba(0,0,0,0)");
|
||||||
|
g.addColorStop(0.4, "rgba(0,0,0,0)");
|
||||||
|
g.addColorStop(0.7, "rgba(0,0,0,0.25)");
|
||||||
|
g.addColorStop(1, "rgba(0,0,0,0.65)");
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, vw, vh);
|
||||||
|
return oc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFrame(
|
||||||
|
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
|
||||||
|
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
|
||||||
|
) {
|
||||||
|
ctx.clearRect(0, 0, cw, ch);
|
||||||
|
for (let i = 0; i < cards.length; i++) {
|
||||||
|
const c = cards[i];
|
||||||
|
const p = ((t / c.cycleSec) + c.phase) % 1;
|
||||||
|
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
|
||||||
|
if (alpha < 0.005) continue;
|
||||||
|
const cy = c.yStart - p * c.travel;
|
||||||
|
const tg = trigs[i];
|
||||||
|
const delta = tg.tiltRad * p;
|
||||||
|
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
|
||||||
|
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
||||||
|
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
|
||||||
|
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
||||||
|
}
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.drawImage(vignette, 0, 0, cw, ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fps = 0, fpsFrames = 0, fpsLast = 0;
|
||||||
|
function tickFps(now: number) {
|
||||||
|
fpsFrames++;
|
||||||
|
if (now - fpsLast >= 500) {
|
||||||
|
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
||||||
|
fpsFrames = 0;
|
||||||
|
fpsLast = now;
|
||||||
|
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountCanvas(el: HTMLCanvasElement) {
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
const ctx = el.getContext("2d")!;
|
||||||
|
let live: RenderState | null = null;
|
||||||
|
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
||||||
|
|
||||||
|
async function syncSize() {
|
||||||
|
const gen = ++buildGen;
|
||||||
|
const [phys, scale] = await Promise.all([win.innerSize(), win.scaleFactor()]);
|
||||||
|
if (gen !== buildGen) return;
|
||||||
|
const logW = phys.width / scale, logH = phys.height / scale;
|
||||||
|
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
||||||
|
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
||||||
|
const built = buildCards(logW, logH);
|
||||||
|
const stamps = built.cards.map(c => buildStamp(c, scale));
|
||||||
|
const vig = buildVignette(logW, logH, scale);
|
||||||
|
el.width = phys.width; el.height = phys.height;
|
||||||
|
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => syncSize());
|
||||||
|
ro.observe(el);
|
||||||
|
syncSize();
|
||||||
|
|
||||||
|
let raf = 0, t0 = -1;
|
||||||
|
function frame(now: number) {
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
if (!live) return;
|
||||||
|
if (t0 < 0) t0 = now;
|
||||||
|
if (showFps) tickFps(now);
|
||||||
|
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
||||||
|
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
|
||||||
|
{#if showCards}
|
||||||
|
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
||||||
|
{#if showFps}
|
||||||
|
<span bind:this={fpsEl} style="position:absolute;top:8px;right:8px;font-family:var(--font-ui);font-size:10px;color:var(--text-faint);z-index:2;pointer-events:none"></span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === "idle" && lockEnabled}
|
||||||
|
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
|
||||||
|
<div style="position:relative;width:{logoLockSize}px;height:{logoLockSize}px">
|
||||||
|
<div class="logo-glow"></div>
|
||||||
|
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoLockSize}px;height:{logoLockSize}px;border-radius:22px;display:block;position:relative" />
|
||||||
|
</div>
|
||||||
|
<div class="pin-block">
|
||||||
|
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||||
|
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
|
||||||
|
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if mode === "idle"}
|
||||||
|
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
||||||
|
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
||||||
|
<div class="logo-glow"></div>
|
||||||
|
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoIdleSize}px;height:{logoIdleSize}px;border-radius:28px;display:block;position:relative" />
|
||||||
|
</div>
|
||||||
|
<p class="hint">press any key to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div style="position:relative;width:{logoLoadingSize}px;height:{logoLoadingSize}px;margin-bottom:20px;z-index:1">
|
||||||
|
{#if !failed && !notConfigured}
|
||||||
|
<svg width={ringSize} height={ringSize}
|
||||||
|
class="loading-ring"
|
||||||
|
class:ring-hide={lockEnabled && pinVisible}
|
||||||
|
style="position:absolute;pointer-events:none;top:{ringTop}px;left:{ringLeft}px">
|
||||||
|
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
||||||
|
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="{ringArc} {ringCirc}"
|
||||||
|
transform="rotate(-90 {ringC} {ringC})"
|
||||||
|
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block" />
|
||||||
|
</div>
|
||||||
|
<p class="title-label">moku</p>
|
||||||
|
|
||||||
|
<div class="bottom-area" style="z-index:1">
|
||||||
|
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
||||||
|
{#if failed || notConfigured}
|
||||||
|
<div class="error-box">
|
||||||
|
<p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
||||||
|
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if lockEnabled}
|
||||||
|
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
|
||||||
|
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||||
|
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
|
||||||
|
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
||||||
|
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
||||||
|
|
||||||
|
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
||||||
|
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
||||||
|
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
||||||
|
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
||||||
|
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
|
||||||
|
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
||||||
|
|
||||||
|
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
|
||||||
|
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
||||||
|
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
||||||
|
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
|
||||||
|
|
||||||
|
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; }
|
||||||
|
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
|
||||||
|
.error-actions { display: flex; gap: 6px; }
|
||||||
|
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
|
||||||
|
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||||
|
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
|
||||||
|
|
||||||
|
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
|
||||||
|
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
|
||||||
|
.status-slot-hide { opacity: 0; pointer-events: none; }
|
||||||
|
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
||||||
|
.loading-ring { transition: opacity 0.5s ease; }
|
||||||
|
.ring-hide { opacity: 0; }
|
||||||
|
|
||||||
|
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
|
||||||
|
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
||||||
|
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
|
||||||
|
.pin-dots { display: flex; gap: 12px; align-items: center; }
|
||||||
|
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
|
||||||
|
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.pin-shake { animation: pinShake 0.42s ease; }
|
||||||
|
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
const os = platform();
|
||||||
|
const isMac = os === "macos";
|
||||||
|
const isWindows = os === "windows";
|
||||||
|
|
||||||
|
let isFullscreen = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
isFullscreen = await win.isFullscreen();
|
||||||
|
const unlisten = await win.onResized(async () => {
|
||||||
|
isFullscreen = await win.isFullscreen();
|
||||||
|
});
|
||||||
|
return unlisten;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !isFullscreen}
|
||||||
|
<div class="bar" data-tauri-drag-region>
|
||||||
|
{#if isMac}<div class="mac-spacer"></div>{/if}
|
||||||
|
<span class="title" data-tauri-drag-region>Moku</span>
|
||||||
|
{#if !isMac}
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<button class="close" onclick={() => win.close()} title="Close" aria-label="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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if isWindows}
|
||||||
|
<!-- On Windows, fullscreen hides the native titlebar — show a hoverable overlay so the user isn't locked in -->
|
||||||
|
<div class="fullscreen-controls">
|
||||||
|
<button onclick={() => win.setFullscreen(false)} title="Exit Fullscreen" aria-label="Exit Fullscreen">
|
||||||
|
<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"/>
|
||||||
|
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="close" onclick={() => win.close()} title="Close" aria-label="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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fullscreen-controls {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
.fullscreen-controls:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 var(--sp-3) 0 var(--sp-4);
|
||||||
|
background: var(--bg-void);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
/* Spacer to clear the native macOS traffic lights (~70px) */
|
||||||
|
.mac-spacer {
|
||||||
|
width: 70px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
button:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.close:hover { color: #fff; background: #c0392b; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { store, dismissToast } from "../../store/state.svelte";
|
||||||
|
import type { Toast } from "../../store/state.svelte";
|
||||||
|
|
||||||
|
const EXIT_MS = 280;
|
||||||
|
const leaving = new Set<string>();
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
function schedule(t: Toast) {
|
||||||
|
if (timers.has(t.id)) return;
|
||||||
|
const dur = t.duration ?? 3500;
|
||||||
|
if (dur === 0) return;
|
||||||
|
timers.set(t.id, setTimeout(() => dismiss(t.id), dur));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id: string) {
|
||||||
|
if (leaving.has(id)) return;
|
||||||
|
leaving.add(id);
|
||||||
|
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id); }
|
||||||
|
|
||||||
|
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`);
|
||||||
|
if (!el) { finalize(id); return; }
|
||||||
|
|
||||||
|
const h = el.offsetHeight;
|
||||||
|
el.style.setProperty("--exit-h", `${h}px`);
|
||||||
|
el.classList.add("leaving");
|
||||||
|
setTimeout(() => finalize(id), EXIT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalize(id: string) {
|
||||||
|
leaving.delete(id);
|
||||||
|
dismissToast(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
store.toasts.forEach(schedule);
|
||||||
|
return () => timers.forEach(clearTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
const icons: Record<Toast["kind"], string> = {
|
||||||
|
success: "M20 6L9 17l-5-5",
|
||||||
|
error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
|
||||||
|
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||||
|
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if store.toasts.length}
|
||||||
|
<div class="toaster" aria-live="polite">
|
||||||
|
{#each store.toasts as t (t.id)}
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
class="toast toast-{t.kind}"
|
||||||
|
data-toast-id={t.id}
|
||||||
|
onclick={() => dismiss(t.id)}
|
||||||
|
>
|
||||||
|
<div class="accent-bar"></div>
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d={icons[t.kind]} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="body">
|
||||||
|
<p class="title">{t.title}</p>
|
||||||
|
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toaster {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--sp-5);
|
||||||
|
right: var(--sp-5);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 10px var(--sp-3) 10px 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
|
||||||
|
pointer-events: all;
|
||||||
|
min-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
animation: slideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast:hover {
|
||||||
|
border-color: var(--border-base);
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.06) inset;
|
||||||
|
transform: translateX(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast:active { transform: translateX(0) scale(0.98); }
|
||||||
|
|
||||||
|
:global(.toast.leaving) {
|
||||||
|
animation: slideOut 0.28s cubic-bezier(0.4, 0, 1, 1) forwards !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { opacity: 0; transform: translateX(20px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOut {
|
||||||
|
0% { opacity: 1; transform: translateX(0) scale(1); max-height: var(--exit-h, 80px); margin-bottom: 0; }
|
||||||
|
40% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: var(--exit-h, 80px); margin-bottom: 0; }
|
||||||
|
100% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: 0; margin-bottom: -6px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-bar {
|
||||||
|
width: 3px;
|
||||||
|
align-self: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success .accent-bar { background: var(--accent-fg); }
|
||||||
|
.toast-error .accent-bar { background: var(--color-error); }
|
||||||
|
.toast-info .accent-bar { background: var(--text-faint); }
|
||||||
|
.toast-download .accent-bar { background: var(--accent-fg); }
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success .icon { color: var(--accent-fg); }
|
||||||
|
.toast-error .icon { color: var(--color-error); }
|
||||||
|
.toast-info .icon { color: var(--text-muted); }
|
||||||
|
.toast-download .icon { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { store } from "../../store/state.svelte";
|
|
||||||
import logoUrl from "../../assets/moku-icon-splash.svg";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mode?: "loading" | "idle";
|
|
||||||
ringFull?: boolean;
|
|
||||||
failed?: boolean;
|
|
||||||
notConfigured?: boolean;
|
|
||||||
showCards?: boolean;
|
|
||||||
showFps?: boolean;
|
|
||||||
onReady?: () => void;
|
|
||||||
onRetry?: () => void;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
|
|
||||||
showCards = true, showFps = false, onReady, onRetry, onDismiss }: Props = $props();
|
|
||||||
|
|
||||||
const EXIT_MS = 320;
|
|
||||||
// Server typically takes 8-20s to boot. We animate the ring through three
|
|
||||||
// phases so it always feels like something is happening:
|
|
||||||
// 0 → 0.75 over ~12s (eased crawl while server starts)
|
|
||||||
// 0.75 → 0.92 over ~8s (slow down near the end, implying "almost there")
|
|
||||||
// jumps to 1.0 the moment the probe succeeds
|
|
||||||
const PHASE1_TARGET = 0.85;
|
|
||||||
const PHASE1_MS = 3000;
|
|
||||||
const PHASE2_TARGET = 0.95;
|
|
||||||
const PHASE2_MS = 10000;
|
|
||||||
|
|
||||||
let dots = $state("");
|
|
||||||
let ringProg = $state(0.025);
|
|
||||||
let exiting = $state(false);
|
|
||||||
let exitLock = false;
|
|
||||||
|
|
||||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
|
||||||
|
|
||||||
function triggerExit(cb?: () => void) {
|
|
||||||
if (exitLock) return;
|
|
||||||
exitLock = true;
|
|
||||||
exiting = true;
|
|
||||||
setTimeout(() => cb?.(), EXIT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate ring progress with easing so it never stalls visually
|
|
||||||
let animFrame: number;
|
|
||||||
let animStart: number | null = null;
|
|
||||||
let animPhase = 1;
|
|
||||||
|
|
||||||
function animateRing(ts: number) {
|
|
||||||
if (exitLock) return;
|
|
||||||
if (animStart === null) animStart = ts;
|
|
||||||
const elapsed = ts - animStart;
|
|
||||||
|
|
||||||
if (animPhase === 1) {
|
|
||||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
|
||||||
// ease-out cubic so it starts fast and slows down
|
|
||||||
const eased = 1 - Math.pow(1 - t, 3);
|
|
||||||
ringProg = 0.025 + eased * (PHASE1_TARGET - 0.025);
|
|
||||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
|
||||||
} else if (animPhase === 2) {
|
|
||||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
|
||||||
const eased = 1 - Math.pow(1 - t, 4);
|
|
||||||
ringProg = PHASE1_TARGET + eased * (PHASE2_TARGET - PHASE1_TARGET);
|
|
||||||
// Phase 2 never completes on its own — only ringFull triggers completion
|
|
||||||
}
|
|
||||||
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (mode === "loading" && !failed && !notConfigured) {
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
|
||||||
return () => cancelAnimationFrame(animFrame);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (ringFull) {
|
|
||||||
cancelAnimationFrame(animFrame);
|
|
||||||
ringProg = 1;
|
|
||||||
setTimeout(() => triggerExit(onReady), 650);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const dotsInterval = setInterval(() => {
|
|
||||||
dots = dots.length >= 3 ? "" : dots + ".";
|
|
||||||
}, 420);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (mode === "idle" && onDismiss) {
|
|
||||||
const handler = () => triggerExit(onDismiss);
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
|
||||||
window.addEventListener("mousedown", handler, { once: true });
|
|
||||||
window.addEventListener("touchstart", handler, { once: true });
|
|
||||||
}, 200);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(t);
|
|
||||||
clearInterval(dotsInterval);
|
|
||||||
window.removeEventListener("keydown", handler);
|
|
||||||
window.removeEventListener("mousedown", handler);
|
|
||||||
window.removeEventListener("touchstart", handler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return () => clearInterval(dotsInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
|
||||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
|
||||||
|
|
||||||
const LAYER_CFG = [
|
|
||||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
|
||||||
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
|
||||||
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const BUF = 80, COLS = 14;
|
|
||||||
|
|
||||||
function hash(n: number): number {
|
|
||||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
|
||||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
|
||||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCards(vw: number, vh: number) {
|
|
||||||
const cards: CardDef[] = [], laneW = vw / COLS;
|
|
||||||
for (let layer = 0; layer < 3; layer++) {
|
|
||||||
const cfg = LAYER_CFG[layer];
|
|
||||||
for (let col = 0; col < COLS; col++) {
|
|
||||||
const seed = col * 31 + layer * 97 + 7;
|
|
||||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
|
||||||
const h = w * 1.44;
|
|
||||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
|
||||||
const travel = vh + h + BUF;
|
|
||||||
cards.push({
|
|
||||||
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
|
||||||
w, h, lines: 1 + Math.floor(hash(seed + 7) * 3), alpha: cfg.alpha, speed,
|
|
||||||
cycleSec: travel / speed,
|
|
||||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
|
||||||
travel, yStart: vh + h / 2 + BUF / 2,
|
|
||||||
angleStart: hash(seed + 3) * 50 - 25,
|
|
||||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const trigs: CardTrig[] = cards.map(c => ({
|
|
||||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
|
||||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
|
||||||
tiltRad: c.tilt * (Math.PI / 180),
|
|
||||||
}));
|
|
||||||
return { cards, trigs };
|
|
||||||
}
|
|
||||||
|
|
||||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
|
||||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
|
||||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
|
||||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
|
||||||
ctx.closePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
const STAMP_PAD = 6;
|
|
||||||
|
|
||||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
|
||||||
const oc = document.createElement("canvas");
|
|
||||||
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
|
|
||||||
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
|
|
||||||
const ctx = oc.getContext("2d")!;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
const x0 = STAMP_PAD, y0 = STAMP_PAD;
|
|
||||||
const coverH = (c.w * 0.72) * 1.05;
|
|
||||||
const lineY0 = y0 + 3 + coverH + 5;
|
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.75)"; ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
|
||||||
for (let li = 0; li < c.lines; li++) {
|
|
||||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
|
||||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
|
||||||
}
|
|
||||||
return oc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
|
||||||
const oc = document.createElement("canvas");
|
|
||||||
oc.width = Math.round(vw * dpr); oc.height = Math.round(vh * dpr);
|
|
||||||
const ctx = oc.getContext("2d")!;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
|
||||||
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(0.4, "rgba(0,0,0,0)"); g.addColorStop(0.7, "rgba(0,0,0,0.25)"); g.addColorStop(1, "rgba(0,0,0,0.65)");
|
|
||||||
ctx.fillStyle = g; ctx.fillRect(0, 0, vw, vh);
|
|
||||||
return oc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawFrame(
|
|
||||||
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
|
|
||||||
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
|
|
||||||
) {
|
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
|
||||||
for (let i = 0; i < cards.length; i++) {
|
|
||||||
const c = cards[i];
|
|
||||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
|
||||||
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
|
|
||||||
if (alpha < 0.005) continue;
|
|
||||||
const cy = c.yStart - p * c.travel;
|
|
||||||
const tg = trigs[i];
|
|
||||||
const delta = tg.tiltRad * p;
|
|
||||||
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
|
|
||||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
|
||||||
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
|
|
||||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
|
||||||
}
|
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1;
|
|
||||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
let fps = 0, fpsFrames = 0, fpsLast = 0;
|
|
||||||
function tickFps(now: number) {
|
|
||||||
fpsFrames++;
|
|
||||||
if (now - fpsLast >= 500) {
|
|
||||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
|
||||||
fpsFrames = 0; fpsLast = now;
|
|
||||||
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountCanvas(el: HTMLCanvasElement) {
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
const ctx = el.getContext("2d")!;
|
|
||||||
interface RenderState {
|
|
||||||
cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[];
|
|
||||||
vignette: HTMLCanvasElement; CW: number; CH: number; scale: number;
|
|
||||||
}
|
|
||||||
let live: RenderState | null = null;
|
|
||||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
|
||||||
|
|
||||||
async function syncSize() {
|
|
||||||
const gen = ++buildGen;
|
|
||||||
const [phys, scale] = await Promise.all([win.innerSize(), win.scaleFactor()]);
|
|
||||||
if (gen !== buildGen) return;
|
|
||||||
const logW = phys.width / scale, logH = phys.height / scale;
|
|
||||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
|
||||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
|
||||||
const built = buildCards(logW, logH);
|
|
||||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
|
||||||
const vig = buildVignette(logW, logH, scale);
|
|
||||||
el.width = phys.width; el.height = phys.height;
|
|
||||||
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => syncSize());
|
|
||||||
ro.observe(el); syncSize();
|
|
||||||
|
|
||||||
let raf = 0, t0 = -1;
|
|
||||||
function frame(now: number) {
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
if (!live) return;
|
|
||||||
if (t0 < 0) t0 = now;
|
|
||||||
if (showFps) tickFps(now);
|
|
||||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
|
||||||
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
|
||||||
}
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ringR = $derived(70);
|
|
||||||
const ringPad = $derived(12);
|
|
||||||
const ringSize = $derived((ringR + ringPad) * 2);
|
|
||||||
const ringC = $derived(ringR + ringPad);
|
|
||||||
const ringCirc = $derived(2 * Math.PI * ringR);
|
|
||||||
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
|
|
||||||
const ringTop = $derived(-((ringSize - 140) / 2));
|
|
||||||
const ringLeft = $derived(-((ringSize - 140) / 2));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' ? 'pointer' : 'default'}">
|
|
||||||
{#if showCards}
|
|
||||||
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
|
||||||
{#if showFps}
|
|
||||||
<span bind:this={fpsEl} style="position:absolute;top:8px;right:8px;font-family:var(--font-ui);font-size:10px;color:var(--text-faint);z-index:2;pointer-events:none"></span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mode === "idle"}
|
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
|
||||||
<div style="position:relative;width:128px;height:128px;margin-bottom:32px">
|
|
||||||
<div class="logo-glow"></div>
|
|
||||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:128px;height:128px;border-radius:28px;display:block;position:relative" />
|
|
||||||
</div>
|
|
||||||
<p class="hint">press any key to continue</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div style="position:relative;width:140px;height:140px;margin-bottom:20px;z-index:1">
|
|
||||||
{#if !failed && !notConfigured}
|
|
||||||
<svg width={ringSize} height={ringSize} style="position:absolute;pointer-events:none;top:{ringTop}px;left:{ringLeft}px">
|
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-dasharray="{ringArc} {ringCirc}" transform="rotate(-90 {ringC} {ringC})" style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" />
|
|
||||||
</div>
|
|
||||||
<p class="title-label">moku</p>
|
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:8px">
|
|
||||||
{#if notConfigured}
|
|
||||||
<div class="error-box">
|
|
||||||
<p class="error-title">Server not configured</p>
|
|
||||||
<p class="error-body">Set the server path in Settings, then retry</p>
|
|
||||||
<div style="display:flex;gap:8px;margin-top:8px">
|
|
||||||
<button class="retry-btn" onclick={() => { store.settingsOpen = true; }}>Settings</button>
|
|
||||||
<button class="retry-btn" onclick={onRetry}>Retry</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if failed}
|
|
||||||
<div class="error-box error-box--danger">
|
|
||||||
<p class="error-title" style="color:var(--color-error)">Could not reach Suwayomi</p>
|
|
||||||
<p class="error-body">Make sure tachidesk-server is on your PATH</p>
|
|
||||||
<button class="retry-btn" style="margin-top:8px" onclick={onRetry}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p style="font-family:var(--font-ui);font-size:10px;color:var(--text-faint);letter-spacing:0.12em;margin:0;min-width:160px;text-align:center">
|
|
||||||
{ringFull ? "Ready" : `Initializing server${dots}`}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
|
||||||
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
|
||||||
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
|
||||||
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
|
||||||
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
|
||||||
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
|
||||||
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
|
|
||||||
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
|
||||||
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
|
||||||
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
|
|
||||||
.retry-btn { margin-top: 4px; padding: 5px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.08em; }
|
|
||||||
.retry-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
|
||||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 14px 20px; border-radius: var(--radius-lg); background: rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.12); max-width: 260px; text-align: center; backdrop-filter: blur(4px); }
|
|
||||||
.error-box--danger { border-color: rgba(220,50,50,0.5); }
|
|
||||||
.error-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.1em; margin: 0; }
|
|
||||||
.error-body { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.05em; margin: 0; line-height: 1.6; }
|
|
||||||
</style>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="bar" data-tauri-drag-region>
|
|
||||||
<span class="title" data-tauri-drag-region>Moku</span>
|
|
||||||
<div class="controls">
|
|
||||||
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
<button class="close" onclick={() => win.close()} title="Close" aria-label="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>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
|
||||||
background: var(--bg-void);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
button:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.close:hover { color: #fff; background: #c0392b; }
|
|
||||||
</style>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { store, dismissToast } from "../../store/state.svelte";
|
|
||||||
import type { Toast } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
function schedule(t: Toast) {
|
|
||||||
if (timers.has(t.id)) return;
|
|
||||||
const dur = t.duration ?? 3500;
|
|
||||||
if (dur === 0) return;
|
|
||||||
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
store.toasts.forEach(schedule);
|
|
||||||
return () => timers.forEach(clearTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
const icons: Record<Toast["kind"], string> = {
|
|
||||||
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.toasts.length}
|
|
||||||
<div class="toaster" aria-live="polite">
|
|
||||||
{#each store.toasts as t (t.id)}
|
|
||||||
<div class="toast toast-{t.kind}" role="alert">
|
|
||||||
<span class="icon">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d={icons[t.kind]} />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div class="body">
|
|
||||||
<p class="title">{t.title}</p>
|
|
||||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
|
||||||
</div>
|
|
||||||
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toaster {
|
|
||||||
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
|
|
||||||
z-index: 9999; display: flex; flex-direction: column;
|
|
||||||
gap: var(--sp-2); pointer-events: none; max-width: 320px;
|
|
||||||
}
|
|
||||||
.toast {
|
|
||||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
|
||||||
pointer-events: all; min-width: 220px;
|
|
||||||
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
|
||||||
}
|
|
||||||
@keyframes toastIn {
|
|
||||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
|
||||||
}
|
|
||||||
.toast-success { border-color: var(--accent-dim); }
|
|
||||||
.toast-success .icon { color: var(--accent-fg); }
|
|
||||||
.toast-error { border-color: var(--color-error); }
|
|
||||||
.toast-error .icon { color: var(--color-error); }
|
|
||||||
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
|
|
||||||
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
|
|
||||||
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
|
|
||||||
.sub {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.close {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 18px; height: 18px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
</style>
|
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle } from "phosphor-svelte";
|
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
|
||||||
import { store, addFolder, assignMangaToFolder, setPreviewManga } from "../../store/state.svelte";
|
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source, Category } from "../../lib/types";
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
const GRID_LIMIT = 200;
|
||||||
const GRID_LIMIT = 60; // max rendered per tab
|
const CONCURRENCY = 6;
|
||||||
const LOCAL_THRESHOLD = 20; // fan out to sources if local results below this
|
const PAGES_INIT = 3;
|
||||||
const CONCURRENCY = 4; // parallel source requests — kept conservative to not saturate connections
|
const PAGES_GENRE = 2;
|
||||||
const BATCH_INTERVAL = 400; // ms between DOM updates during background source fan-out
|
|
||||||
|
|
||||||
const EXPLORE_ALL_MANGA = `
|
const EXPLORE_ALL_MANGA = `
|
||||||
query ExploreAllManga {
|
query ExploreAllManga {
|
||||||
@@ -27,46 +27,53 @@
|
|||||||
`;
|
`;
|
||||||
const MANGAS_BY_GENRE = `
|
const MANGAS_BY_GENRE = `
|
||||||
query MangasByGenre($genre: String!, $first: Int) {
|
query MangasByGenre($genre: String!, $first: Int) {
|
||||||
mangas(
|
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||||
filter: { genre: { includesInsensitive: $genre } }
|
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
||||||
first: $first orderBy: IN_LIBRARY_AT orderByType: DESC
|
}
|
||||||
) { nodes { id title thumbnailUrl inLibrary genre status source { id displayName } } }
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
function dKey(srcId: string, type: string, genre: string, page: number) {
|
||||||
let allManga: Manga[] = $state([]); // local library — loaded once, never triggers lag
|
return `${srcId}|${type}|${genre}:p${page}`;
|
||||||
let allSources: Source[] = $state([]); // all deduped sources — loaded once
|
}
|
||||||
let loadingLib = $state(true);
|
|
||||||
let loadError = $state(false);
|
|
||||||
|
|
||||||
// Per-genre result map. Keyed by genre string.
|
let allSources: Source[] = $state([]);
|
||||||
// "All" key → local library deduped by title
|
let loadingLib = $state(true);
|
||||||
// Each tab key → local + background source results, deduped id+title
|
let loadError = $state(false);
|
||||||
let genreResults = $state(new Map<string, Manga[]>());
|
let currentGenre = $state("All");
|
||||||
let genreLoading = $state(false); // true only during the initial local fetch for a new tab
|
let genreResults = $state(new Map<string, Manga[]>());
|
||||||
let currentGenre = $state("All");
|
let genreLoading = $state(false);
|
||||||
let genreAbort: AbortController | null = null;
|
let refreshing = $state(false);
|
||||||
|
|
||||||
// batch timer handle for background source fan-out
|
let activeCtrl: AbortController | null = null;
|
||||||
let batchTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
// accumulator: source results collected between batches
|
|
||||||
let batchAccum = new Map<string, Manga[]>(); // genre → pending mangas
|
|
||||||
|
|
||||||
// Context menu
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
let isLoading = $state(false);
|
let categories: Category[] = $state([]);
|
||||||
|
let catsLoaded = false;
|
||||||
|
|
||||||
// ── Derived ───────────────────────────────────────────────────────────────────
|
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
||||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
||||||
$effect(() => { isLoading = genreLoading || (currentGenre === "All" && loadingLib); });
|
|
||||||
|
|
||||||
// ── Dedup helper — always apply id first then title ───────────────────────────
|
|
||||||
function dedup(items: Manga[]): Manga[] {
|
function dedup(items: Manga[]): Manga[] {
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
|
function filterOut(mangas: Manga[]): Manga[] {
|
||||||
|
return dedup(mangas.filter(m => {
|
||||||
|
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
|
||||||
|
if (shouldHideNsfw(m, store.settings)) return false;
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotatedSources(): Source[] {
|
||||||
|
const lang = store.settings.preferredExtensionLang || "en";
|
||||||
|
const eligible = allSources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
|
||||||
|
const srcs = dedupeSources(eligible, lang);
|
||||||
|
if (!srcs.length) return [];
|
||||||
|
const off = store.discoverSrcOffset % srcs.length;
|
||||||
|
return [...srcs.slice(off), ...srcs.slice(0, off)];
|
||||||
|
}
|
||||||
|
|
||||||
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
|
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
@@ -78,222 +85,233 @@
|
|||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Batched DOM flush ─────────────────────────────────────────────────────────
|
function pushToGrid(genre: string, incoming: Manga[]) {
|
||||||
// Source fan-out collects results in batchAccum. A timer fires every BATCH_INTERVAL
|
const filtered = filterOut(incoming);
|
||||||
// ms and flushes them into genreResults in one shot — preventing a Svelte re-render
|
if (!filtered.length) return;
|
||||||
// per-source and keeping the grid smooth.
|
const cur = genreResults.get(genre) ?? [];
|
||||||
function startBatchFlush() {
|
genreResults.set(genre, dedup([...cur, ...filtered]).slice(0, GRID_LIMIT));
|
||||||
if (batchTimer) return;
|
genreResults = new Map(genreResults);
|
||||||
batchTimer = setInterval(() => {
|
|
||||||
if (batchAccum.size === 0) return;
|
|
||||||
for (const [genre, incoming] of batchAccum) {
|
|
||||||
const current = genreResults.get(genre) ?? [];
|
|
||||||
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
|
|
||||||
}
|
|
||||||
batchAccum.clear();
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}, BATCH_INTERVAL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopBatchFlush() {
|
async function fanOut(genre: string, ctrl: AbortController) {
|
||||||
if (batchTimer) { clearInterval(batchTimer); batchTimer = null; }
|
const srcs = rotatedSources();
|
||||||
// Final flush of anything remaining
|
if (!srcs.length) return;
|
||||||
if (batchAccum.size > 0) {
|
|
||||||
for (const [genre, incoming] of batchAccum) {
|
|
||||||
const current = genreResults.get(genre) ?? [];
|
|
||||||
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
|
|
||||||
}
|
|
||||||
batchAccum.clear();
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push source results into the accumulator (never touches the DOM directly)
|
const isAll = genre === "All";
|
||||||
function accumulate(genre: string, mangas: Manga[]) {
|
const type = isAll ? "POPULAR" : "SEARCH";
|
||||||
const existing = batchAccum.get(genre) ?? [];
|
const query = isAll ? null : genre;
|
||||||
batchAccum.set(genre, [...existing, ...mangas]);
|
const maxPages = isAll ? PAGES_INIT : PAGES_GENRE;
|
||||||
}
|
|
||||||
|
|
||||||
// ── Background source fan-out for a genre ────────────────────────────────────
|
|
||||||
// Runs entirely in the background. Results appear in batches via batchAccum.
|
|
||||||
// Does NOT set genreLoading = true — the local result is already showing.
|
|
||||||
async function fanOutSources(genre: string, ctrl: AbortController) {
|
|
||||||
if (!allSources.length) return;
|
|
||||||
const lang = store.settings.preferredExtensionLang || "en";
|
|
||||||
const srcs = dedupeSources(allSources, lang);
|
|
||||||
|
|
||||||
startBatchFlush();
|
|
||||||
|
|
||||||
await runConcurrent(srcs, async src => {
|
await runConcurrent(srcs, async src => {
|
||||||
if (ctrl.signal.aborted) return;
|
for (let page = 1; page <= maxPages; page++) {
|
||||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, [genre]);
|
if (ctrl.signal.aborted) return;
|
||||||
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
|
||||||
pageKey,
|
|
||||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
|
||||||
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: genre }, ctrl.signal
|
|
||||||
).then(d => d.fetchSourceManga),
|
|
||||||
5 * 60 * 1000, // 5-min TTL — results are stable enough to cache
|
|
||||||
).catch(() => null);
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
const key = dKey(src.id, type, genre, page);
|
||||||
|
let mangas: Manga[];
|
||||||
|
let hasNextPage = false;
|
||||||
|
|
||||||
// Only accumulate results that actually match the genre (client-side AND check)
|
if (store.discoverCache.has(key)) {
|
||||||
const matching = result.mangas.filter(m =>
|
mangas = store.discoverCache.get(key)!;
|
||||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
} else {
|
||||||
|| result.mangas.length <= 5 // source returns few results, trust them
|
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
);
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type, page, query },
|
||||||
|
ctrl.signal
|
||||||
|
).then(d => d.fetchSourceManga).catch(() => null);
|
||||||
|
|
||||||
accumulate(genre, matching.length > 0 ? matching : result.mangas);
|
if (!result || ctrl.signal.aborted) return;
|
||||||
|
mangas = result.mangas;
|
||||||
|
hasNextPage = result.hasNextPage;
|
||||||
|
store.discoverCache.set(key, mangas);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
if (isAll) {
|
||||||
|
pushToGrid("All", mangas);
|
||||||
|
} else {
|
||||||
|
const matching = mangas.filter(m =>
|
||||||
|
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
||||||
|
);
|
||||||
|
pushToGrid(genre, matching.length ? matching : mangas);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNextPage) return;
|
||||||
|
}
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
|
|
||||||
if (!ctrl.signal.aborted) stopBatchFlush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tab switch ───────────────────────────────────────────────────────────────
|
|
||||||
// 1. Show local results immediately (no spinner if already cached)
|
|
||||||
// 2. If local < LOCAL_THRESHOLD, kick off background fan-out silently
|
|
||||||
async function switchGenre(genre: string) {
|
async function switchGenre(genre: string) {
|
||||||
if (currentGenre === genre) return;
|
if (currentGenre === genre) return;
|
||||||
|
|
||||||
// Abort any in-flight fan-out for the previous tab
|
activeCtrl?.abort();
|
||||||
genreAbort?.abort();
|
|
||||||
stopBatchFlush();
|
|
||||||
|
|
||||||
currentGenre = genre;
|
currentGenre = genre;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
activeCtrl = ctrl;
|
||||||
|
|
||||||
if (genre === "All") {
|
if (genre === "All") {
|
||||||
// "All" is just the deduped local library — no network needed
|
if ((genreResults.get("All") ?? []).length > 0) {
|
||||||
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
genreLoading = false;
|
||||||
|
fanOut("All", ctrl).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
genreResults.set("All", []);
|
||||||
genreResults = new Map(genreResults);
|
genreResults = new Map(genreResults);
|
||||||
|
genreLoading = true;
|
||||||
|
await fanOut("All", ctrl);
|
||||||
|
if (!ctrl.signal.aborted) genreLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we already have a fully-populated cache for this genre, show it instantly
|
const localKey = `local|${genre}`;
|
||||||
const cached = genreResults.get(genre);
|
if (store.discoverCache.has(localKey)) {
|
||||||
if (cached && cached.length >= LOCAL_THRESHOLD) return;
|
genreResults.set(genre, store.discoverCache.get(localKey)!);
|
||||||
|
genreResults = new Map(genreResults);
|
||||||
|
fanOut(genre, ctrl).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch local results (fast — single DB query)
|
|
||||||
genreLoading = true;
|
genreLoading = true;
|
||||||
const ctrl = new AbortController();
|
|
||||||
genreAbort = ctrl;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const localData = await cache.get(CACHE_KEYS.GENRE(genre), () =>
|
const d = await gql<{ mangas: { nodes: Manga[] } }>(
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal)
|
MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal
|
||||||
.then(d => d.mangas.nodes)
|
|
||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
const local = dedup(localData);
|
const local = dedup(d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings)));
|
||||||
|
store.discoverCache.set(localKey, local);
|
||||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
||||||
genreResults = new Map(genreResults);
|
genreResults = new Map(genreResults);
|
||||||
genreLoading = false;
|
genreLoading = false;
|
||||||
|
|
||||||
// If sparse, fan out to sources in the background — no loading state shown
|
fanOut(genre, ctrl).catch(() => {});
|
||||||
if (local.length < LOCAL_THRESHOLD) {
|
|
||||||
fanOutSources(genre, ctrl).catch(() => {}); // fully detached background task
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
if (!ctrl.signal.aborted) genreLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context menu ──────────────────────────────────────────────────────────────
|
async function refresh() {
|
||||||
|
activeCtrl?.abort();
|
||||||
|
clearDiscoverCache();
|
||||||
|
genreResults = new Map();
|
||||||
|
refreshing = true;
|
||||||
|
genreLoading = true;
|
||||||
|
const genre = currentGenre;
|
||||||
|
currentGenre = "";
|
||||||
|
await new Promise(r => setTimeout(r, 20));
|
||||||
|
await switchGenre(genre);
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAll() {
|
||||||
|
loadingLib = true;
|
||||||
|
loadError = false;
|
||||||
|
|
||||||
|
if ((genreResults.get("All") ?? []).length > 0) {
|
||||||
|
loadingLib = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.get(CACHE_KEYS.DISCOVER, () =>
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
||||||
|
).then(m => {
|
||||||
|
store.discoverLibraryIds = new Set(
|
||||||
|
dedupeMangaById(m).filter(x => x.inLibrary).map(x => x.id)
|
||||||
|
);
|
||||||
|
}).catch(e => { console.error(e); loadError = true; })
|
||||||
|
.finally(() => { loadingLib = false; });
|
||||||
|
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then(d => {
|
||||||
|
allSources = d.sources.nodes;
|
||||||
|
if ((currentGenre === "All" || currentGenre === "") &&
|
||||||
|
(genreResults.get("All") ?? []).length === 0) {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
activeCtrl = ctrl;
|
||||||
|
genreLoading = true;
|
||||||
|
fanOut("All", ctrl).then(() => {
|
||||||
|
if (!ctrl.signal.aborted) genreLoading = false;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => { activeCtrl?.abort(); });
|
||||||
|
|
||||||
|
loadAll();
|
||||||
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
|
if (!catsLoaded) {
|
||||||
|
catsLoaded = true;
|
||||||
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: m.inLibrary ? "In Library" : "Add to library",
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
icon: BookmarkSimple, disabled: m.inLibrary,
|
icon: BookmarkSimple, disabled: m.inLibrary,
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
.then(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error),
|
.then(() => {
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
||||||
|
}).catch(console.error),
|
||||||
},
|
},
|
||||||
...(store.settings.folders.length > 0 ? [
|
...(categories.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...store.settings.folders.map(f => ({
|
...categories.map(cat => ({
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
||||||
icon: Folder,
|
icon: Folder,
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||||
})),
|
})),
|
||||||
] : []),
|
] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: "New folder & add", icon: FolderSimplePlus,
|
label: "New folder & add", icon: FolderSimplePlus,
|
||||||
onClick: () => {
|
onClick: async () => {
|
||||||
const n = prompt("Folder name:");
|
const n = prompt("Folder name:");
|
||||||
if (n?.trim()) { const id = addFolder(n.trim()); assignMangaToFolder(id, m.id); }
|
if (!n?.trim()) return;
|
||||||
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: n.trim() }).catch(console.error);
|
||||||
|
if (res) {
|
||||||
|
const cat = res.createCategory.category;
|
||||||
|
categories = [...categories, cat];
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initial load ──────────────────────────────────────────────────────────────
|
|
||||||
// 1. Load local library → populate "All" tab immediately
|
|
||||||
// 2. Load source list in background (needed for genre fan-out, not needed for initial render)
|
|
||||||
function loadAll() {
|
|
||||||
loadingLib = true; loadError = false;
|
|
||||||
const lang = store.settings.preferredExtensionLang || "en";
|
|
||||||
|
|
||||||
// Local library — populates "All" tab
|
|
||||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
|
||||||
).then(m => {
|
|
||||||
allManga = dedupeMangaById(m);
|
|
||||||
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}).catch(e => { console.error(e); loadError = true; })
|
|
||||||
.finally(() => { loadingLib = false; });
|
|
||||||
|
|
||||||
// Source list — loaded silently in background, cached for the session
|
|
||||||
// Not awaited — the grid doesn't depend on this for the initial render
|
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then(d => dedupeSources(d.sources.nodes, lang)),
|
|
||||||
Infinity, // pin for session — source list is stable
|
|
||||||
).then(srcs => {
|
|
||||||
allSources = srcs;
|
|
||||||
}).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(loadAll);
|
|
||||||
onDestroy(() => {
|
|
||||||
genreAbort?.abort();
|
|
||||||
stopBatchFlush();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ── Source browse passthrough ─────────────────────────────────────────────── -->
|
|
||||||
{#if store.activeSource}
|
{#if store.activeSource}
|
||||||
<SourceBrowse />
|
<SourceBrowse />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="root">
|
<div class="root">
|
||||||
|
|
||||||
<!-- ── Header: page label + genre pill tabs ──────────────────────────────── -->
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="heading">Discover</span>
|
<span class="heading">Discover</span>
|
||||||
<div class="tab-strip">
|
<div class="tab-strip">
|
||||||
{#each GENRE_TABS as tab (tab)}
|
{#each GENRE_TABS as tab (tab)}
|
||||||
<button
|
<button class="genre-tab" class:active={currentGenre === tab} onclick={() => switchGenre(tab)}>
|
||||||
class="genre-tab"
|
|
||||||
class:active={currentGenre === tab}
|
|
||||||
onclick={() => switchGenre(tab)}
|
|
||||||
>
|
|
||||||
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
||||||
{tab}
|
{tab}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<button class="refresh-btn" class:spinning={refreshing} onclick={refresh} title="Refresh results" disabled={refreshing}>
|
||||||
|
<ArrowsClockwise size={13} weight="bold" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Body ──────────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
|
{#if isLoading && visibleGrid.length === 0}
|
||||||
{#if isLoading}
|
|
||||||
<!-- Skeleton — shown only during first local fetch, never during bg fan-out -->
|
|
||||||
<div class="manga-grid">
|
<div class="manga-grid">
|
||||||
{#each Array(24) as _, i (i)}
|
{#each Array(24) as _, i (i)}
|
||||||
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
||||||
@@ -312,31 +330,22 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="manga-grid">
|
<div class="manga-grid">
|
||||||
{#each visibleGrid as m (m.id)}
|
{#each visibleGrid as m (m.id)}
|
||||||
<button
|
<button class="manga-card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => openCtx(e, m)}>
|
||||||
class="manga-card"
|
|
||||||
onclick={() => setPreviewManga(m)}
|
|
||||||
oncontextmenu={(e) => openCtx(e, m)}
|
|
||||||
>
|
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||||
src={thumbUrl(m.thumbnailUrl)} alt={m.title}
|
|
||||||
class="cover" loading="lazy" decoding="async"
|
|
||||||
/>
|
|
||||||
<div class="cover-gradient"></div>
|
<div class="cover-gradient"></div>
|
||||||
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<p class="card-title">{m.title}</p>
|
<p class="card-title">{m.title}</p>
|
||||||
{#if m.source?.displayName}
|
{#if m.source?.displayName}<p class="card-source">{m.source.displayName}</p>{/if}
|
||||||
<p class="card-source">{m.source.displayName}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -346,117 +355,36 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
.header { display: flex; align-items: center; gap: var(--sp-4); flex-shrink: 0; padding: var(--sp-3) var(--sp-4) var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); overflow-x: auto; scrollbar-width: none; }
|
||||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-4); flex-shrink: 0;
|
|
||||||
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
|
||||||
overflow-x: auto; scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.header::-webkit-scrollbar { display: none; }
|
.header::-webkit-scrollbar { display: none; }
|
||||||
.heading {
|
.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; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Genre pill tabs */
|
|
||||||
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
.genre-tab {
|
.genre-tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 12px; border-radius: var(--radius-full);
|
|
||||||
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
|
||||||
cursor: pointer; white-space: nowrap;
|
|
||||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.genre-tab:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
.genre-tab:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
.genre-tab.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
.genre-tab.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
|
.refresh-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; margin-left: auto; transition: color var(--t-base), background var(--t-base); }
|
||||||
/* ── Body ────────────────────────────────────────────────────────────────── */
|
.refresh-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
.body {
|
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||||
flex: 1; overflow-y: auto;
|
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
|
||||||
padding: var(--sp-4) var(--sp-5) var(--sp-6);
|
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
/* GPU-accelerated scroll — does NOT promote every card, only the scroll container */
|
.manga-card:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||||
will-change: scroll-position;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Grid ────────────────────────────────────────────────────────────────── */
|
|
||||||
.manga-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr));
|
|
||||||
gap: var(--sp-2);
|
|
||||||
align-content: start;
|
|
||||||
/* Isolate the grid from the rest of the layout — prevents full-page reflow on update */
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Card ────────────────────────────────────────────────────────────────── */
|
|
||||||
.manga-card {
|
|
||||||
background: none; border: none; padding: 0; cursor: pointer; text-align: left;
|
|
||||||
/* NO will-change here — only promote on actual hover to avoid 60+ simultaneous GPU layers */
|
|
||||||
}
|
|
||||||
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
|
||||||
.manga-card:hover .card-title { color: #fff; }
|
.manga-card:hover .card-title { color: #fff; }
|
||||||
/* Promote only the hovered card to its own GPU layer */
|
|
||||||
.manga-card:hover { will-change: transform; }
|
.manga-card:hover { will-change: transform; }
|
||||||
|
.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 8px rgba(0,0,0,0.25); }
|
||||||
.cover-wrap {
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
||||||
position: relative; aspect-ratio: 2/3; overflow: hidden;
|
.cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
||||||
border-radius: var(--radius-md); background: var(--bg-raised);
|
.lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); }
|
||||||
border: 1px solid var(--border-dim);
|
.card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
.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); transition: color var(--t-base); }
|
||||||
}
|
.card-source { 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; }
|
||||||
.cover {
|
|
||||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
|
||||||
transition: filter 0.15s ease, transform 0.15s ease;
|
|
||||||
/* will-change removed — only the parent card gets it on hover */
|
|
||||||
}
|
|
||||||
.cover-gradient {
|
|
||||||
position: absolute; inset: 0;
|
|
||||||
background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.lib-badge {
|
|
||||||
position: absolute; top: var(--sp-1); right: var(--sp-1);
|
|
||||||
font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.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);
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.card-source {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────────────────────────────────────── */
|
|
||||||
.card-skeleton { padding: 0; }
|
.card-skeleton { padding: 0; }
|
||||||
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||||
|
.skeleton { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
|
||||||
/* ── Empty / error ───────────────────────────────────────────────────────── */
|
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.85 } }
|
||||||
.empty {
|
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); padding: var(--sp-10) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
.retry-btn { padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
gap: var(--sp-3); padding: var(--sp-10) var(--sp-6);
|
.refresh-btn.spinning { opacity: 0.5; cursor: default; }
|
||||||
color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs);
|
.refresh-btn.spinning :global(svg) { animation: spin 0.8s linear infinite; }
|
||||||
letter-spacing: var(--tracking-wide);
|
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
}
|
|
||||||
.retry-btn {
|
|
||||||
padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised); color: var(--text-muted); cursor: pointer;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||||
import { store, setActiveDownloads } from "../../store/state.svelte";
|
import { store, setActiveDownloads } from "../../store/state.svelte";
|
||||||
import type { DownloadStatus } from "../../lib/types";
|
import type { DownloadStatus } from "../../lib/types";
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
<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:loading={togglingPlay} onclick={togglePlay}
|
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
|
||||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
||||||
@@ -89,6 +91,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<div class="status-dot" class:active={isRunning}></div>
|
<div class="status-dot" class:active={isRunning}></div>
|
||||||
<span class="status-text">
|
<span class="status-text">
|
||||||
@@ -112,7 +115,7 @@
|
|||||||
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
|
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
|
||||||
{#if manga?.thumbnailUrl}
|
{#if manga?.thumbnailUrl}
|
||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga?.title} class="thumb-img" loading="lazy" decoding="async" />
|
<Thumbnail src={manga.thumbnailUrl} alt={manga?.title} class="thumb-img" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="info">
|
<div class="info">
|
||||||
@@ -139,18 +142,20 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div><!-- .content -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { padding: var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-5); }
|
.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; }
|
.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 { 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 { 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); transition: color var(--t-base), border-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-md); border: 1px solid var(--border-dim); color: var(--text-muted); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
.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); }
|
||||||
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); margin-bottom: var(--sp-4); }
|
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
|
||||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
.status-dot { 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-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
|
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||||
@@ -161,7 +166,7 @@
|
|||||||
.row.row-active { border-color: var(--accent-dim); }
|
.row.row-active { border-color: var(--accent-dim); }
|
||||||
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
||||||
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
.thumb-img { width: 100%; height: 100%; object-fit: cover; }
|
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||||
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy } from "svelte";
|
|
||||||
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { UPDATE_MANGA, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
|
||||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/util";
|
|
||||||
import { settings, activeSource, genreFilter, previewManga, history, addFolder, assignMangaToFolder } from "../../store";
|
|
||||||
import type { Manga, Source } from "../../lib/types";
|
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
import SourceList from "../sources/SourceList.svelte";
|
|
||||||
import SourceBrowse from "../sources/SourceBrowse.svelte";
|
|
||||||
import GenreDrillPage from "./GenreDrillPage.svelte";
|
|
||||||
|
|
||||||
type ExploreMode = "explore" | "sources";
|
|
||||||
let mode: ExploreMode = "explore";
|
|
||||||
|
|
||||||
const EXPLORE_ALL_MANGA = `
|
|
||||||
query ExploreAllManga {
|
|
||||||
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const MANGAS_BY_GENRE_EXPLORE = `
|
|
||||||
query MangasByGenreExplore($genre: String!, $first: Int) {
|
|
||||||
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
|
||||||
const ROW_CAP = 25;
|
|
||||||
const GHOST_COUNT = 3;
|
|
||||||
|
|
||||||
let allManga: Manga[] = [];
|
|
||||||
let popularManga: Manga[] = [];
|
|
||||||
let sources: Source[] = [];
|
|
||||||
let genreResultsMap = new Map<string, Manga[]>();
|
|
||||||
let loadingLib = true;
|
|
||||||
let loadingPopular = true;
|
|
||||||
let loadingGenres = false;
|
|
||||||
let loadError = false;
|
|
||||||
let retryCount = 0;
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
|
||||||
let abortCtrl: AbortController | null = null;
|
|
||||||
let fetchedGenresKey = "";
|
|
||||||
|
|
||||||
function frecencyScore(readAt: number, count: number): number {
|
|
||||||
return count / Math.log((Date.now() - readAt) / 3_600_000 + 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
$: frecencyGenres = (() => {
|
|
||||||
const mangaScores = new Map<number, number>();
|
|
||||||
const mangaReadAt = new Map<number, number>();
|
|
||||||
for (const e of $history) {
|
|
||||||
mangaScores.set(e.mangaId, (mangaScores.get(e.mangaId) ?? 0) + 1);
|
|
||||||
if (e.readAt > (mangaReadAt.get(e.mangaId) ?? 0)) mangaReadAt.set(e.mangaId, e.readAt);
|
|
||||||
}
|
|
||||||
const genreWeights = new Map<string, number>();
|
|
||||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
|
||||||
for (const [mangaId, count] of mangaScores.entries()) {
|
|
||||||
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
|
||||||
for (const g of mangaMap.get(mangaId)?.genre ?? []) genreWeights.set(g, (genreWeights.get(g) ?? 0) + score);
|
|
||||||
}
|
|
||||||
if (genreWeights.size === 0) allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
|
||||||
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
|
|
||||||
return Array.from(genreWeights.entries()).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([g]) => g);
|
|
||||||
})();
|
|
||||||
|
|
||||||
$: continueReading = (() => {
|
|
||||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
|
||||||
for (const e of $history) {
|
|
||||||
if (seen.has(e.mangaId)) continue;
|
|
||||||
seen.add(e.mangaId);
|
|
||||||
const manga = mangaMap.get(e.mangaId);
|
|
||||||
if (!manga) continue;
|
|
||||||
result.push({ manga, chapterName: e.chapterName, progress: e.pageNumber > 0 ? Math.min(e.pageNumber / 20, 1) : 0 });
|
|
||||||
if (result.length >= 12) break;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
})();
|
|
||||||
|
|
||||||
$: recommended = allManga.length && frecencyGenres.length ? (() => {
|
|
||||||
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
|
||||||
return allManga.filter((m) => m.inLibrary && !continueIds.has(m.id) && frecencyGenres.some((g) => (m.genre ?? []).includes(g))).slice(0, 20);
|
|
||||||
})() : [];
|
|
||||||
|
|
||||||
$: if (frecencyGenres.length && allManga.length) loadGenreRows();
|
|
||||||
|
|
||||||
async function loadGenreRows() {
|
|
||||||
const key = frecencyGenres.join(",");
|
|
||||||
if (fetchedGenresKey === key) return;
|
|
||||||
fetchedGenresKey = key;
|
|
||||||
loadingGenres = true;
|
|
||||||
abortCtrl?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortCtrl = ctrl;
|
|
||||||
const streamMap = new Map<string, Manga[]>();
|
|
||||||
await Promise.allSettled(
|
|
||||||
frecencyGenres.map((genre) =>
|
|
||||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(MANGAS_BY_GENRE_EXPLORE, { genre, first: 25 }, ctrl.signal)
|
|
||||||
.then((d) => d.mangas.nodes)
|
|
||||||
).then((mangas) => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
streamMap.set(genre, mangas);
|
|
||||||
genreResultsMap = new Map(streamMap);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).catch(() => {});
|
|
||||||
if (!ctrl.signal.aborted) loadingGenres = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (retryCount >= 0) loadData();
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
if (allManga.length > 0 && retryCount === 0) return;
|
|
||||||
loadingLib = true; loadingPopular = true; loadError = false;
|
|
||||||
const preferredLang = $settings.preferredExtensionLang || "en";
|
|
||||||
if (retryCount > 0) { cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.SOURCES); fetchedGenresKey = ""; }
|
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then((d) => d.mangas.nodes)
|
|
||||||
).then((m) => { allManga = m; }).catch((e) => { console.error(e); loadError = true; }).finally(() => loadingLib = false);
|
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES).then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
|
||||||
).then(async (allSources) => {
|
|
||||||
if (!allSources.length) { loadingPopular = false; return; }
|
|
||||||
const top = getTopSources(allSources).slice(0, 2);
|
|
||||||
sources = allSources;
|
|
||||||
cache.get(CACHE_KEYS.POPULAR, () =>
|
|
||||||
Promise.allSettled(top.map((src) =>
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "POPULAR", page: 1, query: null })
|
|
||||||
.then((d) => d.fetchSourceManga.mangas)
|
|
||||||
)).then((results) => {
|
|
||||||
const merged: Manga[] = [];
|
|
||||||
for (const r of results) if (r.status === "fulfilled") merged.push(...r.value);
|
|
||||||
return dedupeMangaByTitle(merged).slice(0, 30);
|
|
||||||
})
|
|
||||||
).then((m) => popularManga = m).catch(console.error).finally(() => loadingPopular = false);
|
|
||||||
}).catch((e) => { console.error(e); loadError = true; loadingPopular = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
|
||||||
return [
|
|
||||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error) },
|
|
||||||
...($settings.folders.length > 0 ? [
|
|
||||||
{ separator: true } as MenuEntry,
|
|
||||||
...$settings.folders.map((f): MenuEntry => ({
|
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
|
||||||
})),
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowWheel(e: WheelEvent) {
|
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
||||||
const el = e.currentTarget as HTMLDivElement;
|
|
||||||
if (el.scrollLeft <= 0 && el.scrollLeft >= el.scrollWidth - el.clientWidth - 1) return;
|
|
||||||
e.stopPropagation();
|
|
||||||
el.scrollLeft += e.deltaY;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => abortCtrl?.abort());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $activeSource}
|
|
||||||
<SourceBrowse />
|
|
||||||
{:else if $genreFilter}
|
|
||||||
<GenreDrillPage />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1 class="heading">Explore</h1>
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab" class:active={mode === "explore"} on:click={() => mode = "explore"}>
|
|
||||||
<Compass size={11} weight="bold" /> Explore
|
|
||||||
</button>
|
|
||||||
<button class="tab" class:active={mode === "sources"} on:click={() => mode = "sources"}>
|
|
||||||
<List size={11} weight="bold" /> Sources
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:{mode === 'explore' ? 'contents' : 'none'}">
|
|
||||||
<div class="body">
|
|
||||||
|
|
||||||
{#if continueReading.length > 0 || loadingLib}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><BookOpen size={11} weight="bold" /> Continue Reading</span>
|
|
||||||
</div>
|
|
||||||
{#if loadingLib}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each continueReading.slice(0, ROW_CAP) as { manga, chapterName, progress }}
|
|
||||||
<button class="card" on:click={() => previewManga.set(manga)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga }; }}>
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="cover" loading="lazy" decoding="async" />
|
|
||||||
{#if manga.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
|
||||||
{#if progress > 0}<div class="progress-bar"><div class="progress-fill" style="width:{progress * 100}%"></div></div>{/if}
|
|
||||||
</div>
|
|
||||||
<p class="title">{manga.title}</p>
|
|
||||||
{#if chapterName}<p class="subtitle">{chapterName}</p>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if recommended.length > 0 || loadingLib}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><Star size={11} weight="bold" /> Recommended for You</span>
|
|
||||||
</div>
|
|
||||||
{#if loadingLib}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each recommended.slice(0, ROW_CAP) as m}
|
|
||||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
|
||||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if popularManga.length > 0 || loadingPopular}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title">
|
|
||||||
<Fire size={11} weight="bold" />
|
|
||||||
{sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if loadingPopular}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else if sources.length === 0}
|
|
||||||
<div class="no-source">No sources installed. Add extensions first.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each popularManga.slice(0, ROW_CAP) as m}
|
|
||||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
|
||||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each frecencyGenres as genre}
|
|
||||||
{@const items = genreResultsMap.get(genre) ?? []}
|
|
||||||
{@const isLoading = loadingGenres && items.length === 0}
|
|
||||||
{#if isLoading || items.length > 0}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title">{genre}</span>
|
|
||||||
<button class="see-all" on:click={() => genreFilter.set(genre)}>See all <ArrowRight size={11} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each items.slice(0, ROW_CAP) as m}
|
|
||||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
|
||||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if items.length >= ROW_CAP}
|
|
||||||
<button class="explore-more-card" on:click={() => genreFilter.set(genre)}>
|
|
||||||
<div class="explore-more-inner">
|
|
||||||
<ArrowRight size={20} weight="light" class="explore-more-icon" />
|
|
||||||
<span class="explore-more-label">Explore more</span>
|
|
||||||
<span class="explore-more-genre">{genre}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if !loadingLib && !loadingPopular && !loadingGenres && continueReading.length === 0 && recommended.length === 0 && popularManga.length === 0 && frecencyGenres.every((g) => !genreResultsMap.get(g)?.length)}
|
|
||||||
<div class="empty">
|
|
||||||
{#if loadError}
|
|
||||||
<span>Could not reach Suwayomi</span>
|
|
||||||
<span class="empty-hint">Make sure the server is running, then try again.</span>
|
|
||||||
<button class="retry-btn" on:click={() => { loadingLib = true; loadingPopular = true; retryCount++; }}>Retry</button>
|
|
||||||
{:else}
|
|
||||||
<span>Nothing to explore yet</span>
|
|
||||||
<span class="empty-hint">Add manga to your library or install sources to get started.</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if mode === "sources"}<SourceList />{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if ctx}
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-4); }
|
|
||||||
.header-left { display: flex; align-items: center; gap: var(--sp-4); }
|
|
||||||
.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; 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; }
|
|
||||||
.tab { 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); transition: background var(--t-base), color var(--t-base); white-space: nowrap; }
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-5) 0 var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
|
||||||
.section { margin-bottom: var(--sp-6); }
|
|
||||||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-6) var(--sp-3); }
|
|
||||||
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); 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; }
|
|
||||||
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 0; transition: color var(--t-base); }
|
|
||||||
.see-all:hover { color: var(--accent-fg); }
|
|
||||||
.row { display: flex; gap: var(--sp-3); padding: 0 var(--sp-6); overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; scroll-behavior: smooth; }
|
|
||||||
.row::-webkit-scrollbar { display: none; }
|
|
||||||
.card { flex-shrink: 0; width: 110px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
|
||||||
.card:hover .title { color: var(--text-primary); }
|
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
|
||||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
|
||||||
.progress-bar { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: var(--bg-overlay); }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent-fg); border-radius: 0 2px 0 0; transition: width 0.2s 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; transition: color var(--t-base); }
|
|
||||||
.subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); margin-top: 2px; letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.ghost-card { flex-shrink: 0; width: 110px; aspect-ratio: 2/3; pointer-events: none; visibility: hidden; }
|
|
||||||
.skeleton-row { display: flex; gap: var(--sp-3); padding: 0 var(--sp-6); overflow: hidden; }
|
|
||||||
.card-skeleton { flex-shrink: 0; width: 110px; }
|
|
||||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 80%; }
|
|
||||||
.explore-more-card { flex-shrink: 0; width: 110px; aspect-ratio: 2/3; border-radius: var(--radius-md); border: 1px dashed var(--border-strong); background: var(--bg-raised); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: border-color var(--t-base), background var(--t-base); padding: 0; }
|
|
||||||
.explore-more-card:hover { border-color: var(--accent); background: var(--accent-muted); }
|
|
||||||
.explore-more-inner { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-3); pointer-events: none; }
|
|
||||||
:global(.explore-more-icon) { color: var(--text-faint); transition: color var(--t-base); }
|
|
||||||
.explore-more-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); text-align: center; }
|
|
||||||
.explore-more-genre { font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; text-align: center; font-family: var(--font-ui); letter-spacing: var(--tracking-wide); }
|
|
||||||
.no-source { display: flex; align-items: center; justify-content: center; padding: var(--sp-4) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--sp-8) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); gap: var(--sp-2); text-align: center; }
|
|
||||||
.empty-hint { font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; }
|
|
||||||
.retry-btn { margin-top: var(--sp-3); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
</style>
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
|
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
|
||||||
import { store } from "../../store/state.svelte";
|
import { store } from "../../store/state.svelte";
|
||||||
import type { Extension } from "../../lib/types";
|
import type { Extension } from "../../lib/types";
|
||||||
@@ -130,7 +131,18 @@
|
|||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1 class="heading">Extensions</h1>
|
<h1 class="heading">Extensions</h1>
|
||||||
<div class="header-actions">
|
<div class="tabs">
|
||||||
|
{#each FILTERS as f}
|
||||||
|
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
||||||
|
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
|
<input class="search" placeholder="Search" bind:value={search} />
|
||||||
|
</div>
|
||||||
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
|
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
|
||||||
<GitBranch size={14} weight="light" />
|
<GitBranch size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
@@ -153,7 +165,7 @@
|
|||||||
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
|
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
|
||||||
bind:value={externalUrl} disabled={installing}
|
bind:value={externalUrl} disabled={installing}
|
||||||
oninput={() => installError = null}
|
oninput={() => installError = null}
|
||||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} autofocus />
|
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} use:focusOnMount />
|
||||||
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
||||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
||||||
@@ -201,19 +213,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<div class="tabs">
|
|
||||||
{#each FILTERS as f}
|
|
||||||
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
|
||||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search" bind:value={search} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
{@const hasVariants = variants.length > 0}
|
{@const hasVariants = variants.length > 0}
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
<Thumbnail src={primary.iconUrl} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="name">{base}</span>
|
<span class="name">{base}</span>
|
||||||
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
||||||
@@ -282,9 +282,10 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0; }
|
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||||
.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; }
|
.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-1); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); 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-md); color: var(--text-muted); 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); }
|
||||||
.icon-btn:disabled { opacity: 0.4; }
|
.icon-btn:disabled { opacity: 0.4; }
|
||||||
@@ -309,11 +310,11 @@
|
|||||||
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
||||||
.controls { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0; }
|
|
||||||
.tabs { display: flex; gap: 2px; }
|
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
||||||
.tab { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: none; background: none; color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
|
.tab { 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); }
|
||||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
.tab:hover { color: var(--text-muted); }
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); }
|
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||||
.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); }
|
||||||
@@ -323,12 +324,11 @@
|
|||||||
.group { display: flex; flex-direction: column; }
|
.group { display: flex; flex-direction: column; }
|
||||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
.icon { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.meta { display: 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-wide); }
|
.meta { display: 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-wide); }
|
||||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
||||||
.update-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 2px 6px; flex-shrink: 0; }
|
|
||||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
||||||
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
||||||
@@ -346,3 +346,7 @@
|
|||||||
.variant-actions { flex-shrink: 0; }
|
.variant-actions { flex-shrink: 0; }
|
||||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script module>
|
||||||
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
|
||||||
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source, Category } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
@@ -35,19 +36,21 @@
|
|||||||
|
|
||||||
let libraryManga: Manga[] = $state([]);
|
let libraryManga: Manga[] = $state([]);
|
||||||
let sourceManga: Manga[] = $state([]);
|
let sourceManga: Manga[] = $state([]);
|
||||||
let loadingInitial = true;
|
let loadingInitial = $state(true);
|
||||||
let loadingMore = false;
|
let loadingMore = $state(false);
|
||||||
let visibleCount = PAGE_SIZE;
|
let visibleCount = $state(PAGE_SIZE);
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
|
let categories: Category[] = $state([]);
|
||||||
|
let catsLoaded = false;
|
||||||
|
|
||||||
const nextPageMap = new Map<string, number>();
|
const nextPageMap = new Map<string, number>();
|
||||||
let sources: Source[] = $state([]);
|
let sources: Source[] = $state([]);
|
||||||
let abortCtrl: AbortController | null = null;
|
let abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
const filtered = $derived.by(() => {
|
const filtered = $derived.by(() => {
|
||||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
|
||||||
const libIds = new Set(libMatches.map((m) => m.id));
|
const libIds = new Set(libMatches.map((m) => m.id));
|
||||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]);
|
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
|
||||||
});
|
});
|
||||||
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
||||||
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
||||||
@@ -143,19 +146,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault();
|
||||||
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
|
if (!catsLoaded) {
|
||||||
|
catsLoaded = true;
|
||||||
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
return [
|
return [
|
||||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||||
...(store.settings.folders.length > 0 ? [
|
...(categories.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...store.settings.folders.map((f): MenuEntry => ({
|
...categories.map((cat): MenuEntry => ({
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||||
})),
|
})),
|
||||||
] : []),
|
] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
{ label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
const res = await gql<{ createCategory: { category: Category } }>(
|
||||||
|
CREATE_CATEGORY,
|
||||||
|
{ name: name.trim() }
|
||||||
|
).catch(console.error);
|
||||||
|
if (res) {
|
||||||
|
const cat = (res as any).createCategory.category;
|
||||||
|
categories = [...categories, cat];
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||||
|
}
|
||||||
|
}},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,9 +216,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each visibleItems as m (m.id)}
|
{#each visibleItems as m (m.id)}
|
||||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="card-title">{m.title}</p>
|
<p class="card-title">{m.title}</p>
|
||||||
@@ -222,10 +248,10 @@
|
|||||||
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
.card:hover :global(.cover) { filter: brightness(1.06); }
|
||||||
.card:hover .card-title { color: var(--text-primary); }
|
.card:hover .card-title { color: var(--text-primary); }
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||||
.card-skeleton { padding: 0; }
|
.card-skeleton { padding: 0; }
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, X as XIcon } from "phosphor-svelte";
|
|
||||||
import { thumbUrl, gql } from "../../lib/client";
|
|
||||||
import { GET_CHAPTERS } from "../../lib/queries";
|
|
||||||
import { store, openReader, clearHistory, clearHistoryForManga } from "../../store/state.svelte";
|
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
let search = $state("");
|
|
||||||
let confirmClearAll = $state(false);
|
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "Just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayLabel(ts: number): string {
|
|
||||||
const d = new Date(ts), now = new Date();
|
|
||||||
if (d.toDateString() === now.toDateString()) return "Today";
|
|
||||||
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
|
||||||
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
|
||||||
const weekAgo = new Date(now); weekAgo.setDate(now.getDate() - 7);
|
|
||||||
if (d > weekAgo) return d.toLocaleDateString("en-US", { weekday: "long" });
|
|
||||||
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReadTime(mins: number): string {
|
|
||||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
|
||||||
if (mins < 60) return `${Math.round(mins)}m`;
|
|
||||||
const h = Math.floor(mins / 60), r = mins % 60;
|
|
||||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
|
||||||
const d = Math.floor(h / 24), rh = h % 24;
|
|
||||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
|
||||||
latestChapterId: number; latestChapterName: string; latestPageNumber: number;
|
|
||||||
firstChapterName: string; chapterCount: number; readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessions(entries: HistoryEntry[]): Session[] {
|
|
||||||
if (!entries.length) return [];
|
|
||||||
const sessions: Session[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < entries.length) {
|
|
||||||
const anchor = entries[i];
|
|
||||||
const group: HistoryEntry[] = [anchor];
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < entries.length) {
|
|
||||||
const next = entries[j];
|
|
||||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) { group.push(next); j++; }
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
const latest = group[0], oldest = group[group.length - 1];
|
|
||||||
sessions.push({ mangaId: latest.mangaId, mangaTitle: latest.mangaTitle, thumbnailUrl: latest.thumbnailUrl, latestChapterId: latest.chapterId, latestChapterName: latest.chapterName, latestPageNumber: latest.pageNumber, firstChapterName: oldest.chapterName, chapterCount: group.length, readAt: latest.readAt });
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
return sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = $derived(search.trim()
|
|
||||||
? store.history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
|
||||||
: store.history);
|
|
||||||
|
|
||||||
const sessions = $derived(buildSessions(filtered));
|
|
||||||
|
|
||||||
const groups = $derived((() => {
|
|
||||||
const map = new Map<string, Session[]>();
|
|
||||||
for (const s of sessions) {
|
|
||||||
const l = dayLabel(s.readAt);
|
|
||||||
if (!map.has(l)) map.set(l, []);
|
|
||||||
map.get(l)!.push(s);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
|
||||||
})());
|
|
||||||
|
|
||||||
const stats = $derived({
|
|
||||||
uniqueChapters: new Set(store.history.map(e => e.chapterId)).size,
|
|
||||||
uniqueManga: new Set(store.history.map(e => e.mangaId)).size,
|
|
||||||
estimatedMinutes: Math.round(new Set(store.history.map(e => e.chapterId)).size * 4.5),
|
|
||||||
});
|
|
||||||
|
|
||||||
function doConfirmClear() { clearHistory(); confirmClearAll = false; }
|
|
||||||
|
|
||||||
async function resume(session: Session) {
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: any[] } }>(GET_CHAPTERS, { mangaId: session.mangaId });
|
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const ch = chapters.find(c => c.id === session.latestChapterId) ?? chapters[0];
|
|
||||||
if (ch) openReader(ch, chapters);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="heading">History</h1>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search store.history…" bind:value={search} />
|
|
||||||
{#if search}
|
|
||||||
<button class="search-clear" onclick={() => search = ""}>
|
|
||||||
<XIcon size={10} weight="bold" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.history.length > 0}
|
|
||||||
{#if confirmClearAll}
|
|
||||||
<div class="confirm-row">
|
|
||||||
<span class="confirm-label">Clear all activity?</span>
|
|
||||||
<button class="confirm-yes" onclick={doConfirmClear}>Clear</button>
|
|
||||||
<button class="confirm-no" onclick={() => confirmClearAll = false}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="clear-btn" onclick={() => confirmClearAll = true} title="Clear all activity">
|
|
||||||
<Trash size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats-bar">
|
|
||||||
<span class="stat-item"><span class="stat-val">{stats.uniqueChapters}</span><span class="stat-label">chapters</span></span>
|
|
||||||
<span class="stat-sep"></span>
|
|
||||||
<span class="stat-item"><span class="stat-val">{stats.uniqueManga}</span><span class="stat-label">series</span></span>
|
|
||||||
<span class="stat-sep"></span>
|
|
||||||
<span class="stat-item"><span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span><span class="stat-label">est. time</span></span>
|
|
||||||
{#if store.readingStats.currentStreakDays > 0}
|
|
||||||
<span class="stat-sep"></span>
|
|
||||||
<span class="stat-item"><span class="stat-val">{store.readingStats.currentStreakDays}d</span><span class="stat-label">streak</span></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if store.history.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No reading history yet</p>
|
|
||||||
<p class="empty-hint">Chapters you read will appear here</p>
|
|
||||||
</div>
|
|
||||||
{:else if sessions.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<Books size={28} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No results for "{search}"</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="list">
|
|
||||||
{#each groups as { label, items } (label)}
|
|
||||||
<div class="group">
|
|
||||||
<p class="group-label">
|
|
||||||
<span>{label}</span>
|
|
||||||
<span class="group-count">{items.length}</span>
|
|
||||||
</p>
|
|
||||||
{#each items as session (session.latestChapterId + ":" + session.readAt)}
|
|
||||||
<div class="row-wrap">
|
|
||||||
<button class="row" onclick={() => resume(session)}>
|
|
||||||
<div class="thumb-wrap">
|
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" loading="lazy" decoding="async" />
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
<span class="session-badge">{session.chapterCount}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="info">
|
|
||||||
<span class="manga-title">{session.mangaTitle}</span>
|
|
||||||
<span class="chapter-name">
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
<span class="chapter-range">{session.firstChapterName}<span class="range-sep">→</span>{session.latestChapterName}</span>
|
|
||||||
{:else}
|
|
||||||
{session.latestChapterName}
|
|
||||||
{#if session.latestPageNumber > 1}<span class="page-badge">p.{session.latestPageNumber}</span>{/if}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="time">{timeAgo(session.readAt)}</span>
|
|
||||||
<Play size={11} weight="fill" class="play-icon" />
|
|
||||||
</button>
|
|
||||||
<button class="row-delete" onclick={() => clearHistoryForManga(session.mangaId)} title="Remove {session.mangaTitle} from store.history" aria-label="Remove from store.history">
|
|
||||||
<XIcon size={9} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-5) var(--sp-6) var(--sp-3); 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-right { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 28px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.search-clear { position: absolute; right: 7px; display: flex; align-items: center; justify-content: center; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
|
||||||
.search-clear:hover { color: var(--text-muted); }
|
|
||||||
.confirm-row { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.confirm-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.confirm-yes { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--color-error); background: var(--color-error-bg); color: var(--color-error); cursor: pointer; transition: filter var(--t-base); }
|
|
||||||
.confirm-yes:hover { filter: brightness(1.15); }
|
|
||||||
.confirm-no { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: background var(--t-base); }
|
|
||||||
.confirm-no:hover { background: var(--bg-raised); color: var(--text-muted); }
|
|
||||||
.clear-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.stats-bar { display: flex; align-items: center; gap: var(--sp-3); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; }
|
|
||||||
.stat-item { display: flex; align-items: baseline; gap: 4px; }
|
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.stat-sep { width: 1px; height: 10px; background: var(--border-dim); flex-shrink: 0; }
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-6); scrollbar-width: none; }
|
|
||||||
.list::-webkit-scrollbar { display: none; }
|
|
||||||
.group { margin-bottom: var(--sp-4); }
|
|
||||||
.group-label { display: 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; padding: var(--sp-1) var(--sp-2) var(--sp-2); }
|
|
||||||
.group-count { font-family: var(--font-ui); font-size: 9px; color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); padding: 1px 5px; border-radius: var(--radius-full); letter-spacing: 0; text-transform: none; }
|
|
||||||
.row-wrap { display: flex; align-items: center; border-radius: var(--radius-md); transition: background var(--t-fast); }
|
|
||||||
.row-wrap:hover { background: var(--bg-raised); }
|
|
||||||
.row-wrap:hover .row-delete { opacity: 1; }
|
|
||||||
.row { flex: 1; display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; min-width: 0; }
|
|
||||||
.row:hover :global(.play-icon) { opacity: 1; }
|
|
||||||
.row-delete { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-base), color var(--t-base), background var(--t-base); margin-right: var(--sp-1); }
|
|
||||||
.row-delete:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
|
||||||
.thumb { width: 36px; height: 52px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
|
||||||
.session-badge { position: absolute; bottom: -4px; right: -6px; background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: 9px; font-weight: 600; padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none; }
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.manga-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.chapter-name { font-size: var(--text-sm); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
|
|
||||||
.chapter-range { display: flex; align-items: center; gap: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted); }
|
|
||||||
.range-sep { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
|
||||||
.page-badge { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
|
||||||
:global(.play-icon) { color: var(--text-faint); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
|
||||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
|
||||||
:global(.empty-icon) { color: var(--text-faint); }
|
|
||||||
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } from "../../lib/queries";
|
import { getBlobUrl } from "../../lib/imageCache";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
|
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { store, openReader, COMPLETED_FOLDER_ID, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
import type { HistoryEntry } from "../../store/state.svelte";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||||
|
import { buildReaderChapterList } from "../../lib/chapterList";
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||||
@@ -22,7 +25,7 @@
|
|||||||
function formatReadTime(mins: number): string {
|
function formatReadTime(mins: number): string {
|
||||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
||||||
if (mins < 60) return `${Math.round(mins)}m`;
|
if (mins < 60) return `${Math.round(mins)}m`;
|
||||||
const h = Math.floor(mins / 60), r = mins % 60;
|
const h = Math.floor(mins / 60), r = Math.round(mins % 60);
|
||||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||||
const d = Math.floor(h / 24), rh = h % 24;
|
const d = Math.floor(h / 24), rh = h % 24;
|
||||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
||||||
@@ -30,20 +33,54 @@
|
|||||||
|
|
||||||
function focusEl(node: HTMLElement) { node.focus(); }
|
function focusEl(node: HTMLElement) { node.focus(); }
|
||||||
|
|
||||||
let libraryManga: Manga[] = $state([]);
|
let libraryManga: Manga[] = $state([]);
|
||||||
let extraManga: Manga[] = $state([]);
|
let extraManga: Manga[] = $state([]);
|
||||||
let loadingLibrary: boolean = $state(true);
|
let loadingLibrary: boolean = $state(true);
|
||||||
|
let completedCategory: Category | null = $state(null);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
loadLibrary();
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
|
||||||
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => loadingLibrary = false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchExtraCompleted(library: Manga[]) {
|
function loadLibrary() {
|
||||||
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? [];
|
const libraryP = cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||||
|
);
|
||||||
|
const categoriesP = gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then(d => d.categories.nodes.find(c => c.name === "Completed") ?? null)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
Promise.all([libraryP, categoriesP])
|
||||||
|
.then(([m, completed]) => {
|
||||||
|
libraryManga = m;
|
||||||
|
completedCategory = completed;
|
||||||
|
fetchExtraCompleted(m, completed);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => loadingLibrary = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAndReload() {
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
loadingLibrary = true;
|
||||||
|
heroChapters = [];
|
||||||
|
heroAllChapters = [];
|
||||||
|
heroChaptersFor = null;
|
||||||
|
loadLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (store.navPage === "home") untrack(() => resetAndReload());
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const sessionId = store.readerSessionId;
|
||||||
|
if (sessionId === 0) return;
|
||||||
|
untrack(() => resetAndReload());
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchExtraCompleted(library: Manga[], completed: Category | null) {
|
||||||
|
const completedIds = completed?.mangas?.nodes.map(m => m.id) ?? [];
|
||||||
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
||||||
if (!missingIds.length) return;
|
if (!missingIds.length) return;
|
||||||
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
||||||
@@ -86,15 +123,29 @@
|
|||||||
|
|
||||||
let activeIdx = $state(0);
|
let activeIdx = $state(0);
|
||||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
||||||
const heroThumb = $derived(activeSlot?.kind === "pinned" ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") : activeSlot?.kind === "continue" ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") : "");
|
const heroThumbSrc = $derived(
|
||||||
|
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
|
||||||
|
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
|
||||||
|
);
|
||||||
|
let heroThumb = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
const path = heroThumbSrc;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (!path) { heroThumb = ""; return; }
|
||||||
|
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
|
||||||
|
// Use tauri-plugin-http backed getBlobUrl which handles auth and bypasses CORS
|
||||||
|
getBlobUrl(thumbUrl(path))
|
||||||
|
.then(url => { heroThumb = url; })
|
||||||
|
.catch(() => { heroThumb = ""; });
|
||||||
|
});
|
||||||
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
|
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
|
||||||
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
|
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
|
||||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
||||||
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
||||||
|
|
||||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; }
|
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; }
|
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; } }
|
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
||||||
@@ -108,26 +159,31 @@
|
|||||||
|
|
||||||
let heroStageH = $state(300);
|
let heroStageH = $state(300);
|
||||||
let heroChapters: Chapter[] = $state([]);
|
let heroChapters: Chapter[] = $state([]);
|
||||||
|
let heroAllChapters: Chapter[] = $state([]);
|
||||||
let loadingHeroChapters = $state(false);
|
let loadingHeroChapters = $state(false);
|
||||||
let heroChaptersFor: number | null = null;
|
let heroChaptersFor: number | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = heroMangaId;
|
const id = heroMangaId;
|
||||||
if (id && id !== heroChaptersFor) untrack(() => loadHeroChapters(id));
|
void store.settings.mangaPrefs?.[id!];
|
||||||
|
if (id) untrack(() => loadHeroChapters(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadHeroChapters(mangaId: number) {
|
async function loadHeroChapters(mangaId: number) {
|
||||||
heroChaptersFor = mangaId;
|
heroChaptersFor = mangaId;
|
||||||
loadingHeroChapters = true;
|
loadingHeroChapters = true;
|
||||||
heroChapters = [];
|
heroChapters = [];
|
||||||
|
heroAllChapters = [];
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
||||||
if (heroChaptersFor !== mangaId) return;
|
if (heroChaptersFor !== mangaId) return;
|
||||||
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const lastReadIdx = heroEntry ? all.findIndex(c => c.id === heroEntry!.chapterId) : all.findLastIndex(c => c.isRead);
|
heroAllChapters = all;
|
||||||
|
const filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
|
||||||
|
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
|
||||||
const startIdx = Math.max(0, lastReadIdx);
|
const startIdx = Math.max(0, lastReadIdx);
|
||||||
heroChapters = all.slice(startIdx, startIdx + 5);
|
heroChapters = filtered.slice(startIdx, startIdx + 5);
|
||||||
} catch { heroChapters = []; }
|
} catch { heroChapters = []; heroAllChapters = []; }
|
||||||
finally { loadingHeroChapters = false; }
|
finally { loadingHeroChapters = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +193,18 @@
|
|||||||
if (!heroMangaId) return;
|
if (!heroMangaId) return;
|
||||||
resuming = true;
|
resuming = true;
|
||||||
try {
|
try {
|
||||||
let all = heroChapters;
|
let all = heroAllChapters;
|
||||||
if (!all.length) {
|
if (!all.length) {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
}
|
}
|
||||||
openReader(chapter, all);
|
if (all.length) {
|
||||||
|
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
|
||||||
|
store.activeManga = manga;
|
||||||
|
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
|
||||||
|
const target = list.find(c => c.id === chapter.id) ?? list[0];
|
||||||
|
if (target) openReader(target, list);
|
||||||
|
}
|
||||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
@@ -150,15 +212,18 @@
|
|||||||
async function resumeActive() {
|
async function resumeActive() {
|
||||||
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
||||||
if (!heroEntry) return;
|
if (!heroEntry) return;
|
||||||
const target = heroChapters.find(c => c.id === heroEntry!.chapterId) ?? heroChapters[0];
|
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
|
||||||
if (target && heroChapters.length) { await openChapter(target); return; }
|
if (target && heroAllChapters.length) { await openChapter(target); return; }
|
||||||
resuming = true;
|
resuming = true;
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
|
||||||
if (ch) openReader(ch, chapters);
|
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
|
||||||
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
if (ch) {
|
||||||
|
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
||||||
|
openReader(ch, list);
|
||||||
|
}
|
||||||
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
@@ -166,10 +231,13 @@
|
|||||||
async function resumeEntry(entry: HistoryEntry) {
|
async function resumeEntry(entry: HistoryEntry) {
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
||||||
if (ch) openReader(ch, chapters);
|
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
||||||
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
if (ch) {
|
||||||
|
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||||
|
openReader(ch, list);
|
||||||
|
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||||
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,10 +254,10 @@
|
|||||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
||||||
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
||||||
|
|
||||||
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
|
const completedIds = $derived(completedCategory?.mangas?.nodes.map(m => m.id) ?? []);
|
||||||
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
|
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
|
||||||
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []);
|
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 7) : []);
|
||||||
const recentHistory = $derived(store.history.slice(0, 8));
|
const recentHistory = $derived(store.history.slice(0, 6));
|
||||||
const stats = $derived(store.readingStats);
|
const stats = $derived(store.readingStats);
|
||||||
|
|
||||||
function handleRowWheel(e: WheelEvent) {
|
function handleRowWheel(e: WheelEvent) {
|
||||||
@@ -348,7 +416,7 @@
|
|||||||
{#if recentHistory.length > 0}
|
{#if recentHistory.length > 0}
|
||||||
{#each recentHistory as entry (entry.chapterId)}
|
{#each recentHistory as entry (entry.chapterId)}
|
||||||
<button class="activity-row" onclick={() => resumeEntry(entry)}>
|
<button class="activity-row" onclick={() => resumeEntry(entry)}>
|
||||||
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle} class="activity-thumb" loading="lazy" decoding="async" />
|
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="activity-thumb" />
|
||||||
<div class="activity-info">
|
<div class="activity-info">
|
||||||
<span class="activity-title">{entry.mangaTitle}</span>
|
<span class="activity-title">{entry.mangaTitle}</span>
|
||||||
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
|
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
|
||||||
@@ -384,7 +452,7 @@
|
|||||||
<div class="bottom-section-hd">
|
<div class="bottom-section-hd">
|
||||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
||||||
{#if completedManga.length > 0}
|
{#if completedManga.length > 0}
|
||||||
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
|
<button class="see-all" onclick={() => { if (completedCategory) setLibraryFilter(String(completedCategory.id)); store.navPage = "library"; }}>View all <ArrowRight size={9} weight="bold" /></button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if completedManga.length > 0}
|
{#if completedManga.length > 0}
|
||||||
@@ -392,7 +460,7 @@
|
|||||||
{#each completedManga as m (m.id)}
|
{#each completedManga as m (m.id)}
|
||||||
<button class="mini-card" onclick={() => store.previewManga = m}>
|
<button class="mini-card" onclick={() => store.previewManga = m}>
|
||||||
<div class="mini-cover-wrap">
|
<div class="mini-cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="mini-cover" />
|
||||||
<div class="mini-gradient"></div>
|
<div class="mini-gradient"></div>
|
||||||
<div class="mini-footer">
|
<div class="mini-footer">
|
||||||
<p class="mini-card-title">{m.title}</p>
|
<p class="mini-card-title">{m.title}</p>
|
||||||
@@ -428,7 +496,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if pickerOpen}
|
{#if pickerOpen}
|
||||||
<div class="picker-backdrop" onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}>
|
<div class="picker-backdrop" role="presentation"
|
||||||
|
onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}
|
||||||
|
onkeydown={(e) => { if (e.key === "Escape") closePicker(); }}>
|
||||||
<div class="picker-modal">
|
<div class="picker-modal">
|
||||||
<div class="picker-header">
|
<div class="picker-header">
|
||||||
<span class="picker-title">Pin manga — slot {(pickerSlotIndex ?? 0) + 1}</span>
|
<span class="picker-title">Pin manga — slot {(pickerSlotIndex ?? 0) + 1}</span>
|
||||||
@@ -446,7 +516,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each pickerResults as m (m.id)}
|
{#each pickerResults as m (m.id)}
|
||||||
<button class="picker-row" onclick={() => pinManga(m)}>
|
<button class="picker-row" onclick={() => pinManga(m)}>
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="picker-thumb" loading="lazy" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="picker-thumb" />
|
||||||
<div class="picker-info">
|
<div class="picker-info">
|
||||||
<span class="picker-manga-title">{m.title}</span>
|
<span class="picker-manga-title">{m.title}</span>
|
||||||
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
|
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
|
||||||
@@ -536,7 +606,7 @@
|
|||||||
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
|
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
.activity-row:hover .activity-play { opacity: 1; }
|
.activity-row:hover .activity-play { opacity: 1; }
|
||||||
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
:global(.activity-thumb) { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
@@ -549,13 +619,14 @@
|
|||||||
.bottom-col:last-child { padding-left: var(--sp-4); }
|
.bottom-col:last-child { padding-left: var(--sp-4); }
|
||||||
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
||||||
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
||||||
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--sp-3); }
|
.mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
|
||||||
|
.mini-row::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
.mini-card:hover :global(.mini-cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||||
.mini-card:hover { will-change: transform; }
|
.mini-card:hover { will-change: transform; }
|
||||||
.mini-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 12px rgba(0,0,0,0.35); }
|
.mini-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 12px rgba(0,0,0,0.35); }
|
||||||
.mini-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
:global(.mini-cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
||||||
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
|
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
|
||||||
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
||||||
.mini-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); }
|
.mini-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); }
|
||||||
@@ -594,7 +665,7 @@
|
|||||||
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
|
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
|
||||||
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||||
.picker-row:hover { background: var(--bg-raised); }
|
.picker-row:hover { background: var(--bg-raised); }
|
||||||
.picker-thumb { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
|
:global(.picker-thumb) { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
|
||||||
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|||||||
@@ -1,754 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
|
||||||
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage} from "../../store/state.svelte";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
import MigrateModal from "./MigrateModal.svelte";
|
|
||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25;
|
|
||||||
const MANGA_TTL_MS = 5 * 60 * 1000;
|
|
||||||
const CHAPTER_TTL_MS = 2 * 60 * 1000;
|
|
||||||
|
|
||||||
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
|
|
||||||
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
|
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
|
||||||
let chapters: Chapter[] = $state([]);
|
|
||||||
let loadingManga: boolean = $state(false);
|
|
||||||
let loadingChapters: boolean = $state(true);
|
|
||||||
let enqueueing: Set<number> = $state(new Set());
|
|
||||||
let dlOpen: boolean = $state(false);
|
|
||||||
let detailsOpen: boolean = $state(false);
|
|
||||||
let togglingLibrary: boolean = $state(false);
|
|
||||||
let chapterPage: number = $state(1);
|
|
||||||
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
|
|
||||||
let jumpOpen: boolean = $state(false);
|
|
||||||
let jumpInput: string = $state("");
|
|
||||||
let viewMode: "list" | "grid" = $state("list");
|
|
||||||
let deletingAll: boolean = $state(false);
|
|
||||||
let refreshing: boolean = $state(false);
|
|
||||||
let descExpanded: boolean = $state(false);
|
|
||||||
let genresExpanded: boolean = $state(false);
|
|
||||||
let folderPickerOpen: boolean = $state(false);
|
|
||||||
let folderCreating: boolean = $state(false);
|
|
||||||
let folderNewName: string = $state("");
|
|
||||||
let rangeFrom: string = $state("");
|
|
||||||
let rangeTo: string = $state("");
|
|
||||||
let showRange: boolean = $state(false);
|
|
||||||
let migrateOpen: boolean = $state(false);
|
|
||||||
let dlDropRef: HTMLDivElement | undefined = $state();
|
|
||||||
let folderPickerRef: HTMLDivElement | undefined = $state();
|
|
||||||
|
|
||||||
let mangaAbort: AbortController | null = null;
|
|
||||||
let chapterAbort: AbortController | null = null;
|
|
||||||
let loadingFor: number | null = null;
|
|
||||||
|
|
||||||
function formatDate(ts: string | null | undefined): string {
|
|
||||||
if (!ts) return "";
|
|
||||||
const n = Number(ts);
|
|
||||||
const d = new Date(n > 1e10 ? n : n * 1000);
|
|
||||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyChapters(nodes: Chapter[]) {
|
|
||||||
chapters = nodes;
|
|
||||||
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortDir = $derived(store.settings.chapterSortDir);
|
|
||||||
const sortedChapters = $derived(sortDir === "desc" ? [...chapters].reverse() : [...chapters]);
|
|
||||||
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 readCount = $derived(chapters.filter(c => c.isRead).length);
|
|
||||||
const totalCount = $derived(chapters.length);
|
|
||||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
|
||||||
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
|
|
||||||
|
|
||||||
const continueChapter = $derived((() => {
|
|
||||||
if (!chapters.length) return null;
|
|
||||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const anyRead = asc.some(c => c.isRead);
|
|
||||||
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
|
||||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
|
||||||
const firstUnread = asc.find(c => !c.isRead);
|
|
||||||
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const };
|
|
||||||
return { chapter: asc[0], type: "reread" as const };
|
|
||||||
})());
|
|
||||||
|
|
||||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
|
||||||
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
|
|
||||||
const hasFolders = $derived(assignedFolders.length > 0);
|
|
||||||
|
|
||||||
function loadManga(id: number) {
|
|
||||||
mangaAbort?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
mangaAbort = ctrl;
|
|
||||||
loadingFor = id;
|
|
||||||
const cached = mangaStore.get(id);
|
|
||||||
if (cached) {
|
|
||||||
manga = cached.data; loadingManga = false;
|
|
||||||
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return;
|
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
|
|
||||||
manga = d.manga;
|
|
||||||
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
|
|
||||||
}).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadingManga = true;
|
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
|
|
||||||
manga = d.manga;
|
|
||||||
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
|
|
||||||
}).catch(() => {}).finally(() => { if (!ctrl.signal.aborted && loadingFor === id) loadingManga = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadChapters(id: number) {
|
|
||||||
chapterAbort?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
chapterAbort = ctrl;
|
|
||||||
const cached = chapterStore.get(id);
|
|
||||||
if (cached) {
|
|
||||||
applyChapters(cached.data); loadingChapters = false;
|
|
||||||
if (Date.now() - cached.fetchedAt < CHAPTER_TTL_MS) return;
|
|
||||||
gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
|
|
||||||
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
|
|
||||||
.then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
|
|
||||||
applyChapters(d.chapters.nodes);
|
|
||||||
}).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chapters = []; loadingChapters = true;
|
|
||||||
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal).then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
applyChapters(d.chapters.nodes); loadingChapters = false;
|
|
||||||
return gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
|
|
||||||
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
|
|
||||||
.then(fresh => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
chapterStore.set(id, { data: fresh.chapters.nodes, fetchedAt: Date.now() });
|
|
||||||
applyChapters(fresh.chapters.nodes);
|
|
||||||
});
|
|
||||||
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const m = store.activeManga;
|
|
||||||
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); });
|
|
||||||
});
|
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
|
||||||
$effect(() => {
|
|
||||||
const wasOpen = prevChapterId !== null;
|
|
||||||
prevChapterId = store.activeChapter?.id ?? null;
|
|
||||||
if (wasOpen && !store.activeChapter && store.activeManga) {
|
|
||||||
const id = store.activeManga.id;
|
|
||||||
untrack(() => { loadChapters(id); cache.clear(CACHE_KEYS.LIBRARY); });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function toggleLibrary() {
|
|
||||||
if (!manga) return;
|
|
||||||
togglingLibrary = true;
|
|
||||||
const next = !manga.inLibrary;
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
|
||||||
manga = { ...manga, inLibrary: next };
|
|
||||||
if (mangaStore.has(manga.id)) { const e = mangaStore.get(manga.id)!; mangaStore.set(manga.id, { ...e, data: manga }); }
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
togglingLibrary = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reloadChapters(id: number) {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id });
|
|
||||||
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
|
|
||||||
applyChapters(d.chapters.nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueue(ch: Chapter, e: MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
enqueueing = new Set(enqueueing).add(ch.id);
|
|
||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
|
|
||||||
addToast({ kind: "download", title: "Download queued", body: ch.name });
|
|
||||||
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
|
||||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
|
||||||
if (!chapterIds.length) return;
|
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
|
||||||
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
|
|
||||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markRead(chapterId: number, isRead: boolean) {
|
|
||||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markBulk(ids: number[], isRead: boolean) {
|
|
||||||
if (!ids.length) return;
|
|
||||||
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
|
||||||
const idSet = new Set(ids);
|
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
|
|
||||||
const markBelowRead = (i: number) => markBulk(sortedChapters.slice(i).filter(c => !c.isRead).map(c => c.id), true);
|
|
||||||
const markAboveUnread = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => c.isRead).map(c => c.id), false);
|
|
||||||
const markBelowUnread = (i: number) => markBulk(sortedChapters.slice(i).filter(c => c.isRead).map(c => c.id), false);
|
|
||||||
|
|
||||||
async function deleteDownloaded(chapterId: number) {
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isDownloaded: false } : c);
|
|
||||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads() {
|
|
||||||
const ids = chapters.filter(c => c.isDownloaded).map(c => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
deletingAll = true;
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
|
||||||
chapters = chapters.map(c => ({ ...c, isDownloaded: false }));
|
|
||||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
|
||||||
deletingAll = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshChapters() {
|
|
||||||
if (!store.activeManga || refreshing) return;
|
|
||||||
refreshing = true;
|
|
||||||
chapterStore.delete(store.activeManga.id);
|
|
||||||
gql(FETCH_CHAPTERS, { mangaId: store.activeManga.id })
|
|
||||||
.then(() => reloadChapters(store.activeManga!.id))
|
|
||||||
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
|
|
||||||
.catch(e => addToast({ kind: "error", title: "Refresh failed", body: e?.message }))
|
|
||||||
.finally(() => refreshing = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
|
|
||||||
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1;
|
|
||||||
return [
|
|
||||||
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "Mark above as read", icon: CheckCircle, onClick: () => markAboveRead(idx), disabled: idx === 0 || above.filter(c => !c.isRead).length === 0 },
|
|
||||||
{ label: "Mark above as unread", icon: Circle, onClick: () => markAboveUnread(idx), disabled: idx === 0 || above.filter(c => c.isRead).length === 0 },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "Mark below as read", icon: CheckCircle, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
|
|
||||||
{ label: "Mark below as unread", icon: Circle, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
|
|
||||||
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
|
|
||||||
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
|
|
||||||
else document.removeEventListener("mousedown", handleDlOutside, true);
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
|
|
||||||
else document.removeEventListener("mousedown", handleFolderOutside, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
function enqueueNext(n: number) {
|
|
||||||
if (!continueChapter) return;
|
|
||||||
const idx = sortedChapters.indexOf(continueChapter.chapter);
|
|
||||||
if (idx < 0) return;
|
|
||||||
enqueueMultiple(sortedChapters.slice(idx, idx + n).filter(c => !c.isDownloaded).map(c => c.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function enqueueRange() {
|
|
||||||
const from = parseFloat(rangeFrom), to = parseFloat(rangeTo);
|
|
||||||
if (isNaN(from) || isNaN(to)) return;
|
|
||||||
const lo = Math.min(from, to), hi = Math.max(from, to);
|
|
||||||
enqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFolder() {
|
|
||||||
const name = folderNewName.trim();
|
|
||||||
if (!name || !store.activeManga) return;
|
|
||||||
const id = addFolder(name);
|
|
||||||
assignMangaToFolder(id, store.activeManga.id);
|
|
||||||
folderNewName = ""; folderCreating = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.activeManga}
|
|
||||||
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<button class="back" onclick={() => setActiveManga(null)}>
|
|
||||||
<ArrowLeft size={13} weight="light" /> Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loadingManga}
|
|
||||||
<div class="meta-skeleton">
|
|
||||||
<div class="skeleton sk-line" style="width:90%;height:14px"></div>
|
|
||||||
<div class="skeleton sk-line" style="width:60%;height:11px"></div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="meta">
|
|
||||||
<p class="title">{manga?.title}</p>
|
|
||||||
{#if manga?.author || manga?.artist}
|
|
||||||
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
|
|
||||||
{/if}
|
|
||||||
{#if statusLabel}
|
|
||||||
<span class="status-badge" class:ongoing={manga?.status === "ONGOING"} class:ended={manga?.status !== "ONGOING"}>{statusLabel}</span>
|
|
||||||
{/if}
|
|
||||||
{#if manga?.genre?.length}
|
|
||||||
<div class="genres">
|
|
||||||
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 5)) as g}
|
|
||||||
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button>
|
|
||||||
{/each}
|
|
||||||
{#if manga.genre.length > 5}
|
|
||||||
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
|
|
||||||
{genresExpanded ? "less" : `+${manga.genre.length - 5}`}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if manga?.description}
|
|
||||||
<div class="desc-wrap">
|
|
||||||
<p class="desc" class:expanded={descExpanded}>{manga.description}</p>
|
|
||||||
{#if manga.description.length > 120}
|
|
||||||
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>{descExpanded ? "Less" : "More"}</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if totalCount > 0}
|
|
||||||
<div class="progress-section">
|
|
||||||
<div class="progress-header">
|
|
||||||
<span class="progress-label">{readCount} / {totalCount} read</span>
|
|
||||||
<span class="progress-pct">{Math.round(progressPct)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress-track"><div class="progress-fill" style="width:{progressPct}%"></div></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button class="library-btn" class:active={manga?.inLibrary} onclick={toggleLibrary} disabled={togglingLibrary || loadingManga}>
|
|
||||||
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
|
|
||||||
{manga?.inLibrary ? "In Library" : "Add to Library"}
|
|
||||||
</button>
|
|
||||||
{#if manga?.realUrl}
|
|
||||||
<a href={manga.realUrl} target="_blank" rel="noreferrer" class="external-link">
|
|
||||||
<ArrowSquareOut size={13} weight="light" />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if continueChapter}
|
|
||||||
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, sortedChapters)}>
|
|
||||||
<Play size={12} weight="fill" />
|
|
||||||
{continueChapter.type === "continue"
|
|
||||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
|
||||||
: continueChapter.type === "reread" ? "Read again" : "Start reading"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<p class="chapter-count">{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}</p>
|
|
||||||
|
|
||||||
{#if !loadingManga && manga?.source}
|
|
||||||
<div class="details-section">
|
|
||||||
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}>
|
|
||||||
<span>Details</span>
|
|
||||||
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
|
|
||||||
</button>
|
|
||||||
{#if detailsOpen}
|
|
||||||
<div class="details-body">
|
|
||||||
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
|
|
||||||
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
|
|
||||||
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
|
|
||||||
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
|
|
||||||
<button class="migrate-btn" onclick={() => migrateOpen = true}>
|
|
||||||
<ArrowsClockwise size={12} weight="light" /> Switch source
|
|
||||||
</button>
|
|
||||||
{#if downloadedCount > 0}
|
|
||||||
<button class="delete-all-btn" onclick={deleteAllDownloads} disabled={deletingAll}>
|
|
||||||
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete downloads (${downloadedCount})`}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="list-wrap">
|
|
||||||
<div class="list-header">
|
|
||||||
<div class="list-header-left">
|
|
||||||
<button class="sort-btn" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; }}>
|
|
||||||
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
|
||||||
{sortDir === "desc" ? "Newest first" : "Oldest first"}
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"}>
|
|
||||||
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="list-header-right">
|
|
||||||
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
|
|
||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
|
||||||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
|
||||||
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
|
||||||
</button>
|
|
||||||
{#if folderPickerOpen}
|
|
||||||
<div class="fp-menu">
|
|
||||||
{#if store.settings.folders.length === 0 && !folderCreating}
|
|
||||||
<p class="fp-empty">No folders yet</p>
|
|
||||||
{/if}
|
|
||||||
{#each store.settings.folders as folder}
|
|
||||||
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false}
|
|
||||||
<button class="fp-item" class:fp-item-active={isIn}
|
|
||||||
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}>
|
|
||||||
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<div class="fp-div"></div>
|
|
||||||
{#if folderCreating}
|
|
||||||
<div class="fp-create">
|
|
||||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} autofocus />
|
|
||||||
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
|
|
||||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
|
||||||
<X size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if chapters.length > 1}
|
|
||||||
<div class="jump-wrap">
|
|
||||||
{#if !jumpOpen}
|
|
||||||
<button class="jump-toggle" onclick={() => { jumpOpen = true; jumpInput = ""; }}>Go to…</button>
|
|
||||||
{:else}
|
|
||||||
<div class="jump-row">
|
|
||||||
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} autofocus
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Escape") { jumpOpen = false; return; }
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
const num = parseFloat(jumpInput);
|
|
||||||
if (!isNaN(num)) {
|
|
||||||
const target = sortedChapters.find(c => c.chapterNumber === num)
|
|
||||||
?? sortedChapters.reduce((best, c) => Math.abs(c.chapterNumber - num) < Math.abs(best.chapterNumber - num) ? c : best, sortedChapters[0]);
|
|
||||||
if (target) openReader(target, sortedChapters);
|
|
||||||
}
|
|
||||||
jumpOpen = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button class="jump-cancel" onclick={() => jumpOpen = false}>✕</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if chapters.length > 0}
|
|
||||||
<div class="dl-wrap" bind:this={dlDropRef}>
|
|
||||||
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}>
|
|
||||||
<Download size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
{#if dlOpen}
|
|
||||||
<div class="dl-dropdown">
|
|
||||||
{#if continueChapter}
|
|
||||||
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
|
|
||||||
{#if contIdx >= 0}
|
|
||||||
<p class="dl-section-label">From Ch.{continueChapter.chapter.chapterNumber}</p>
|
|
||||||
<div class="dl-next-row">
|
|
||||||
{#each [5, 10, 25] as n}
|
|
||||||
{@const avail = sortedChapters.slice(contIdx, contIdx + n).filter(c => !c.isDownloaded).length}
|
|
||||||
<button class="dl-next-btn" disabled={avail === 0} onclick={() => { enqueueNext(n); dlOpen = false; }}>
|
|
||||||
<span>Next {n}</span><span class="dl-next-sub">{avail} new</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="dl-divider"></div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if !showRange}
|
|
||||||
<button class="dl-item" onclick={() => showRange = true}>
|
|
||||||
<span>Custom range…</span><span class="dl-item-sub">Enter chapter numbers</span>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div class="dl-range-row">
|
|
||||||
<button class="dl-range-back" onclick={() => showRange = false}>‹</button>
|
|
||||||
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} autofocus />
|
|
||||||
<span class="dl-range-sep">–</span>
|
|
||||||
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
|
|
||||||
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="dl-divider"></div>
|
|
||||||
<button class="dl-item" onclick={() => { enqueueMultiple(sortedChapters.filter(c => !c.isRead && !c.isDownloaded).map(c => c.id)); dlOpen = false; }}>
|
|
||||||
<span>Unread chapters</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isRead && !c.isDownloaded).length} remaining</span>
|
|
||||||
</button>
|
|
||||||
<button class="dl-item" onclick={() => { enqueueMultiple(sortedChapters.filter(c => !c.isDownloaded).map(c => c.id)); dlOpen = false; }}>
|
|
||||||
<span>Download all</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isDownloaded).length} not downloaded</span>
|
|
||||||
</button>
|
|
||||||
{#if downloadedCount > 0}
|
|
||||||
<div class="dl-divider"></div>
|
|
||||||
<button class="dl-item dl-item-danger" onclick={() => { deleteAllDownloads(); dlOpen = false; }} disabled={deletingAll}>
|
|
||||||
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
|
||||||
<span class="dl-item-sub">{downloadedCount} downloaded</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<div class="pagination">
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}>←</button>
|
|
||||||
<span class="page-num">{chapterPage} / {totalPages}</span>
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.min(totalPages, chapterPage + 1)} disabled={chapterPage === totalPages}>→</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}>
|
|
||||||
{#if loadingChapters && chapters.length === 0}
|
|
||||||
{#if viewMode === "grid"}
|
|
||||||
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
|
||||||
{:else}
|
|
||||||
{#each Array(8) as _}<div class="row-skeleton"><div class="skeleton sk-line" style="width:55%;height:12px"></div><div class="skeleton sk-line" style="width:25%;height:11px"></div></div>{/each}
|
|
||||||
{/if}
|
|
||||||
{:else if viewMode === "grid"}
|
|
||||||
{#each sortedChapters as ch, i}
|
|
||||||
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
|
||||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:bookmarked={ch.isBookmarked}
|
|
||||||
onclick={() => openReader(ch, sortedChapters)}
|
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
|
||||||
title={ch.name}>
|
|
||||||
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
|
||||||
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
|
||||||
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
{#each pageChapters as ch}
|
|
||||||
{@const idxInSorted = sortedChapters.indexOf(ch)}
|
|
||||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead}
|
|
||||||
onclick={() => openReader(ch, sortedChapters)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)}
|
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
|
||||||
<div class="ch-left">
|
|
||||||
<span class="ch-name">{ch.name}</span>
|
|
||||||
<div class="ch-meta">
|
|
||||||
{#if ch.scanlator}<span class="ch-meta-item">{ch.scanlator}</span>{/if}
|
|
||||||
{#if ch.uploadDate}<span class="ch-meta-item">{formatDate(ch.uploadDate)}</span>{/if}
|
|
||||||
{#if ch.lastPageRead && ch.lastPageRead > 0 && !ch.isRead}<span class="ch-meta-item">p.{ch.lastPageRead}</span>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ch-right">
|
|
||||||
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
|
||||||
{#if ch.isDownloaded}
|
|
||||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
|
|
||||||
{:else if enqueueing.has(ch.id)}
|
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
|
||||||
{:else}
|
|
||||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<div class="pagination-bottom">
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}>← Prev</button>
|
|
||||||
<span class="page-num">{chapterPage} / {totalPages}</span>
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.min(totalPages, chapterPage + 1)} disabled={chapterPage === totalPages}>Next →</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if ctx}
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.chapter, ctx.idx)} onClose={() => ctx = null} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if migrateOpen && manga}
|
|
||||||
<MigrateModal
|
|
||||||
{manga}
|
|
||||||
currentChapters={chapters}
|
|
||||||
onClose={() => migrateOpen = false}
|
|
||||||
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.sidebar { width: 200px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
|
||||||
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); }
|
|
||||||
.back:hover { color: var(--text-secondary); }
|
|
||||||
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.sk-line { border-radius: var(--radius-sm); }
|
|
||||||
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
|
||||||
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); line-height: var(--leading-snug); letter-spacing: var(--tracking-tight); }
|
|
||||||
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
|
||||||
.status-badge { display: inline-block; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-sm); width: fit-content; }
|
|
||||||
.status-badge.ongoing { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.status-badge.ended { background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim); }
|
|
||||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
|
||||||
.genre { font-size: var(--text-2xs); font-family: var(--font-ui); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
|
||||||
.desc-wrap { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
||||||
.desc.expanded { -webkit-line-clamp: unset; display: block; overflow: visible; }
|
|
||||||
.desc-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); background: none; border: none; padding: 0; cursor: pointer; opacity: 0.7; transition: opacity var(--t-base); }
|
|
||||||
.desc-toggle:hover { opacity: 1; }
|
|
||||||
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
|
||||||
.progress-header { display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
|
||||||
.actions { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.library-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); background: var(--bg-raised); transition: border-color var(--t-base), color var(--t-base), background var(--t-base); flex: 1; }
|
|
||||||
.library-btn:hover { border-color: var(--accent); color: var(--accent-fg); }
|
|
||||||
.library-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.library-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.external-link { 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-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.read-btn:hover { background: var(--accent-muted); border-color: var(--accent-bright); }
|
|
||||||
.chapter-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
|
|
||||||
.details-section { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
|
|
||||||
.details-toggle:hover { color: var(--text-muted); }
|
|
||||||
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
|
|
||||||
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
|
|
||||||
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
|
|
||||||
.migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px var(--sp-2); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; }
|
|
||||||
.delete-all-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.delete-all-btn:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.delete-all-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
||||||
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; }
|
|
||||||
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
|
||||||
.sort-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.sort-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.fp-wrap { position: relative; }
|
|
||||||
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
|
||||||
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
.fp-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.fp-item:hover { background: var(--bg-overlay); }
|
|
||||||
.fp-item.fp-item-active { color: var(--accent-fg); }
|
|
||||||
.fp-check { width: 12px; font-size: var(--text-xs); color: var(--accent-fg); flex-shrink: 0; }
|
|
||||||
.fp-div { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
|
||||||
.fp-create { display: flex; align-items: center; gap: var(--sp-1); padding: 4px var(--sp-2); }
|
|
||||||
.fp-input { flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; min-width: 0; }
|
|
||||||
.fp-input:focus { border-color: var(--border-focus); }
|
|
||||||
.fp-confirm { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; }
|
|
||||||
.fp-confirm:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.fp-cancel { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.fp-cancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
|
||||||
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
|
|
||||||
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
|
||||||
.jump-wrap { position: relative; }
|
|
||||||
.jump-toggle { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.jump-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.jump-row { display: flex; align-items: center; gap: 4px; }
|
|
||||||
.jump-input { width: 64px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); outline: none; }
|
|
||||||
.jump-input:focus { border-color: var(--border-focus); }
|
|
||||||
.jump-cancel { font-size: 12px; color: var(--text-faint); padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
|
|
||||||
.jump-cancel:hover { color: var(--text-muted); }
|
|
||||||
.dl-wrap { position: relative; }
|
|
||||||
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
|
||||||
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.dl-next-row { display: flex; gap: 4px; padding: 2px var(--sp-2) var(--sp-2); }
|
|
||||||
.dl-next-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 5px; padding: 5px 6px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); }
|
|
||||||
.dl-next-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.dl-next-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.dl-next-sub { font-size: var(--text-2xs); color: var(--text-faint); }
|
|
||||||
.dl-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
|
||||||
.dl-item { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.dl-item:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.dl-item:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.dl-item.dl-item-danger { color: var(--color-error); }
|
|
||||||
.dl-item.dl-item-danger:hover:not(:disabled) { background: var(--color-error-bg); }
|
|
||||||
.dl-item-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
.dl-range-row { display: flex; align-items: center; gap: 4px; padding: 7px var(--sp-2); }
|
|
||||||
.dl-range-back { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 14px; cursor: pointer; }
|
|
||||||
.dl-range-back:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.dl-range-input { flex: 1; min-width: 0; padding: 4px 8px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); outline: none; text-align: center; }
|
|
||||||
.dl-range-input:focus { border-color: var(--border-focus); }
|
|
||||||
.dl-range-sep { color: var(--text-faint); font-size: var(--text-xs); }
|
|
||||||
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
|
|
||||||
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.ch-list { flex: 1; overflow-y: auto; }
|
|
||||||
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
|
||||||
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
|
||||||
.ch-row:hover { background: var(--bg-raised); }
|
|
||||||
.ch-row.read { opacity: 0.45; }
|
|
||||||
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
.ch-name { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
|
||||||
.ch-meta-item { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.ch-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
:global(.read-icon) { color: var(--text-faint); }
|
|
||||||
:global(.enqueue-icon) { color: var(--text-faint); }
|
|
||||||
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); opacity: 0; }
|
|
||||||
.ch-row:hover .dl-btn { opacity: 1; }
|
|
||||||
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.row-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); padding: 12px var(--sp-4); border-bottom: 1px solid var(--border-dim); }
|
|
||||||
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
|
|
||||||
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
|
||||||
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
|
|
||||||
.grid-cell.in-progress { border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.grid-cell-num { font-size: 10px; }
|
|
||||||
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
|
|
||||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
|
||||||
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
|
||||||
@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,894 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
|
||||||
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
|
import {
|
||||||
|
GET_ALL_TRACKER_RECORDS,
|
||||||
|
UPDATE_TRACK,
|
||||||
|
UNBIND_TRACK,
|
||||||
|
FETCH_TRACK,
|
||||||
|
} from "../../lib/queries";
|
||||||
|
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||||
|
import type { Tracker, TrackRecord } from "../../lib/types";
|
||||||
|
|
||||||
|
interface TrackerWithRecords extends Tracker {
|
||||||
|
trackRecords: { nodes: TrackRecord[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlatRecord extends TrackRecord {
|
||||||
|
tracker: Tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
let trackers: TrackerWithRecords[] = $state([]);
|
||||||
|
let loading: boolean = $state(true);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
|
let activeTrackerId: number | "all" = $state("all");
|
||||||
|
let statusFilter: number | "all" = $state("all");
|
||||||
|
let searchQuery: string = $state("");
|
||||||
|
let sortBy: "title" | "status" | "score" | "progress" = $state("title");
|
||||||
|
|
||||||
|
let updatingId: number | null = $state(null);
|
||||||
|
let syncingId: number | null = $state(null);
|
||||||
|
let editingChapter: number | null = $state(null);
|
||||||
|
let chapterDraft: number = $state(0);
|
||||||
|
let confirmUnbindRecord: FlatRecord | null = $state(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 loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||||
|
|
||||||
|
const allRecords: FlatRecord[] = $derived(
|
||||||
|
loggedInTrackers.flatMap(t =>
|
||||||
|
t.trackRecords.nodes.map(r => ({
|
||||||
|
...r,
|
||||||
|
trackerId: r.trackerId ?? t.id,
|
||||||
|
tracker: t as Tracker,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCount = $derived(allRecords.length);
|
||||||
|
|
||||||
|
const statusOptions = $derived.by(() => {
|
||||||
|
if (activeTrackerId === "all") {
|
||||||
|
const seen = new Map<string, { value: number; name: string }>();
|
||||||
|
for (const t of loggedInTrackers)
|
||||||
|
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
|
||||||
|
return [...seen.values()];
|
||||||
|
}
|
||||||
|
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
let list = activeTrackerId === "all"
|
||||||
|
? allRecords
|
||||||
|
: allRecords.filter(r => Number(r.trackerId) === Number(activeTrackerId));
|
||||||
|
|
||||||
|
if (statusFilter !== "all")
|
||||||
|
list = list.filter(r => Number(r.status) === Number(statusFilter));
|
||||||
|
|
||||||
|
if (searchQuery.trim())
|
||||||
|
list = list.filter(r =>
|
||||||
|
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
r.manga?.title?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
if (sortBy === "title") return a.title.localeCompare(b.title);
|
||||||
|
if (sortBy === "status") return a.status - b.status;
|
||||||
|
if (sortBy === "score") return parseFloat(b.displayScore ?? "0") - parseFloat(a.displayScore ?? "0");
|
||||||
|
if (sortBy === "progress") {
|
||||||
|
const ap = a.totalChapters > 0 ? a.lastChapterRead / a.totalChapters : 0;
|
||||||
|
const bp = b.totalChapters > 0 ? b.lastChapterRead / b.totalChapters : 0;
|
||||||
|
return bp - ap;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function updateStatus(record: FlatRecord, status: number) {
|
||||||
|
updatingId = record.id;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, status }
|
||||||
|
);
|
||||||
|
patchRecord(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 }
|
||||||
|
);
|
||||||
|
patchRecord(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 }
|
||||||
|
);
|
||||||
|
patchRecord(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 = trackers.map(t =>
|
||||||
|
t.id !== record.trackerId ? t : {
|
||||||
|
...t,
|
||||||
|
trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
|
||||||
|
trackers = trackers.map(t =>
|
||||||
|
t.id !== trackerId ? t : {
|
||||||
|
...t,
|
||||||
|
trackRecords: {
|
||||||
|
nodes: t.trackRecords.nodes.map(r => r.id === updated.id ? { ...r, ...updated } : r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cancelChapterEditor() { editingChapter = 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 }
|
||||||
|
);
|
||||||
|
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally { updatingId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestUnbind(record: FlatRecord) {
|
||||||
|
confirmUnbindRecord = record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelUnbind() {
|
||||||
|
confirmUnbindRecord = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAndUnbind() {
|
||||||
|
if (!confirmUnbindRecord) return;
|
||||||
|
const record = confirmUnbindRecord;
|
||||||
|
confirmUnbindRecord = null;
|
||||||
|
await unbind(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreToStars(score: string | undefined, scores: string[] | undefined): number {
|
||||||
|
if (!score || !scores || scores.length === 0) return 0;
|
||||||
|
const idx = scores.indexOf(score);
|
||||||
|
if (idx < 0) return 0;
|
||||||
|
return Math.round((idx / (scores.length - 1)) * 5);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-top">
|
||||||
|
<h1 class="heading">Tracking</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh all">
|
||||||
|
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !loading && loggedInTrackers.length > 0}
|
||||||
|
<div class="tracker-tabs">
|
||||||
|
<button
|
||||||
|
class="tracker-tab"
|
||||||
|
class:tab-active={activeTrackerId === "all"}
|
||||||
|
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
<span class="tab-count">{totalCount}</span>
|
||||||
|
</button>
|
||||||
|
{#each loggedInTrackers as t}
|
||||||
|
{@const count = t.trackRecords.nodes.length}
|
||||||
|
<button
|
||||||
|
class="tracker-tab"
|
||||||
|
class:tab-active={activeTrackerId === t.id}
|
||||||
|
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
|
||||||
|
>
|
||||||
|
<Thumbnail src={t.icon} alt={t.name} class="tab-tracker-icon" />
|
||||||
|
{t.name}
|
||||||
|
<span class="tab-count">{count}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<MagnifyingGlass size={12} weight="light" class="search-ico" />
|
||||||
|
<input
|
||||||
|
class="filter-search"
|
||||||
|
placeholder="Search titles…"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="filter-right">
|
||||||
|
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-body">
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="state-center">
|
||||||
|
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
|
<span class="state-label">Loading…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if error}
|
||||||
|
<div class="state-center">
|
||||||
|
<p class="state-error">{error}</p>
|
||||||
|
<button class="retry-btn" onclick={load}>Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if loggedInTrackers.length === 0}
|
||||||
|
<div class="state-center">
|
||||||
|
<p class="state-text">No trackers connected.</p>
|
||||||
|
<p class="state-hint">Go to Settings → Tracking to connect AniList, MAL, or others.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<div class="state-center">
|
||||||
|
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results." : "Nothing tracked yet."}</p>
|
||||||
|
{#if searchQuery || statusFilter !== "all"}
|
||||||
|
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="records-grid">
|
||||||
|
{#each filtered as record (record.tracker.id + ":" + record.id)}
|
||||||
|
{@const tracker = record.tracker}
|
||||||
|
{@const isBusy = updatingId === record.id}
|
||||||
|
{@const isSyncing = syncingId === record.id}
|
||||||
|
{@const progress = record.totalChapters > 0
|
||||||
|
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
|
||||||
|
: null}
|
||||||
|
{@const stars = scoreToStars(record.displayScore, tracker.scores)}
|
||||||
|
{@const statusName = (tracker.statuses ?? []).find(s => s.value === record.status)?.name ?? "—"}
|
||||||
|
|
||||||
|
<div class="record-card" class:record-busy={isBusy}>
|
||||||
|
|
||||||
|
<div class="card-cover-wrap">
|
||||||
|
<div class="card-cover-region"
|
||||||
|
role="button" tabindex="0"
|
||||||
|
onclick={() => openManga(record)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||||
|
title="Open in library"
|
||||||
|
>
|
||||||
|
{#if record.manga?.thumbnailUrl}
|
||||||
|
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="card-cover-img" />
|
||||||
|
{:else}
|
||||||
|
<div class="card-cover-empty"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-top-actions">
|
||||||
|
{#if record.private}
|
||||||
|
<span class="card-badge-btn" title="Private"><Lock size={10} weight="fill" /></span>
|
||||||
|
{/if}
|
||||||
|
{#if isSyncing}
|
||||||
|
<span class="card-badge-btn">
|
||||||
|
<CircleNotch size={10} weight="light" class="anim-spin" />
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<button class="card-badge-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="card-badge-btn" title="Open on {tracker.name}">
|
||||||
|
<ArrowSquareOut size={10} weight="light" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button class="card-badge-btn danger" title="Unlink" onclick={() => requestUnbind(record)} disabled={isBusy}>
|
||||||
|
<X size={10} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-tracker-badge">
|
||||||
|
<Thumbnail src={tracker.icon} alt={tracker.name} class="tracker-badge-img" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="card-stars">
|
||||||
|
{#each Array(5) as _, i}
|
||||||
|
<span class="star" class:star-filled={i < stars}>★</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-title-block"
|
||||||
|
role="button" tabindex="0"
|
||||||
|
onclick={() => openManga(record)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||||
|
>
|
||||||
|
<span class="card-title">{record.title}</span>
|
||||||
|
{#if record.manga?.title && record.manga.title !== record.title}
|
||||||
|
<span class="card-local-title">{record.manga.title}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-meta-row">
|
||||||
|
<select
|
||||||
|
class="status-pill"
|
||||||
|
value={record.status}
|
||||||
|
disabled={isBusy}
|
||||||
|
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
||||||
|
>
|
||||||
|
{#each (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 (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-editor-label">Chapter</span>
|
||||||
|
<div class="chapter-input-wrap">
|
||||||
|
<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") cancelChapterEditor(); }}
|
||||||
|
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-editor-actions">
|
||||||
|
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
||||||
|
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="progress-block clickable"
|
||||||
|
role="button" tabindex="0"
|
||||||
|
onclick={() => openChapterEditor(record)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||||
|
title="Click to edit chapter"
|
||||||
|
>
|
||||||
|
<div class="progress-labels">
|
||||||
|
<span class="progress-text">
|
||||||
|
{#if progress !== null}
|
||||||
|
Ch. {record.lastChapterRead} / {record.totalChapters}
|
||||||
|
{:else if record.lastChapterRead > 0}
|
||||||
|
Ch. {record.lastChapterRead} read
|
||||||
|
{:else}
|
||||||
|
Set chapter…
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if progress !== null}
|
||||||
|
<span class="progress-pct">{Math.round(progress)}%</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmUnbindRecord}
|
||||||
|
{@const r = confirmUnbindRecord}
|
||||||
|
<div class="modal-backdrop" role="presentation" onclick={cancelUnbind}>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="modal-icon">
|
||||||
|
<X size={18} 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 tracking list. This won't affect your progress on {r.tracker.name} itself.
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-cancel" onclick={cancelUnbind}>Cancel</button>
|
||||||
|
<button class="modal-confirm" onclick={confirmAndUnbind}>Unlink</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
display: flex; flex-direction: column; height: 100%; overflow: hidden;
|
||||||
|
animation: fadeIn 0.16s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
.header-top {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.icon-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||||||
|
border: none; color: var(--text-faint); background: 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: 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); }
|
||||||
|
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
|
||||||
|
:global(.tab-tracker-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
|
||||||
|
.tab-count {
|
||||||
|
font-size: 10px; padding: 0 4px; border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-overlay); color: var(--text-faint);
|
||||||
|
min-width: 16px; text-align: center; line-height: 16px;
|
||||||
|
}
|
||||||
|
.tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
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-search {
|
||||||
|
flex: 1; background: none; border: none; outline: none;
|
||||||
|
font-size: var(--text-sm); color: var(--text-primary); min-width: 0;
|
||||||
|
}
|
||||||
|
.filter-search::placeholder { color: var(--text-faint); }
|
||||||
|
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
||||||
|
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.page-body {
|
||||||
|
flex: 1; overflow-y: auto; padding: var(--sp-5);
|
||||||
|
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-center {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; gap: var(--sp-3); height: 100%;
|
||||||
|
padding: var(--sp-10); text-align: center;
|
||||||
|
}
|
||||||
|
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.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); }
|
||||||
|
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
||||||
|
.retry-btn {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 14px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: none;
|
||||||
|
color: var(--text-faint); cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.records-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-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), opacity var(--t-base), transform var(--t-base);
|
||||||
|
}
|
||||||
|
.record-card:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.record-busy { opacity: 0.35; pointer-events: none; }
|
||||||
|
|
||||||
|
.card-cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-cover-region {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.card-cover-img) {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
object-fit: cover; display: block;
|
||||||
|
transition: transform 0.35s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.card-cover-wrap:hover :global(.card-cover-img) {
|
||||||
|
transform: scale(1.04);
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
.card-cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.card-stars {
|
||||||
|
display: flex; gap: 3px; align-items: center;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
.star {
|
||||||
|
font-size: 15px; line-height: 1;
|
||||||
|
color: var(--border-strong);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.star-filled { color: #f5c518; }
|
||||||
|
|
||||||
|
.card-top-actions {
|
||||||
|
position: absolute; top: 6px; right: 6px; z-index: 2;
|
||||||
|
display: flex; gap: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.card-cover-wrap:hover .card-top-actions { opacity: 1; }
|
||||||
|
|
||||||
|
.card-badge-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; border-radius: var(--radius-sm);
|
||||||
|
background: rgba(0,0,0,0.6); backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
color: rgba(255,255,255,0.75); cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.card-badge-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
|
||||||
|
.card-badge-btn.danger:hover { background: rgba(180,40,40,0.7); color: #fff; }
|
||||||
|
.card-badge-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
.card-tracker-badge {
|
||||||
|
position: absolute; bottom: 9px; right: 9px; z-index: 2;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.35);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.55);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
:global(.tracker-badge-img) {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
object-fit: contain; display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer panel ───────────────────────────────────────────────────────── */
|
||||||
|
.card-footer {
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
padding: 13px 13px 13px;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title */
|
||||||
|
.card-title-block {
|
||||||
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
|
cursor: pointer; min-width: 0;
|
||||||
|
}
|
||||||
|
.card-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);
|
||||||
|
}
|
||||||
|
.card-title-block:hover .card-title { color: var(--accent-fg); }
|
||||||
|
.card-local-title {
|
||||||
|
font-family: var(--font-ui); font-size: 11px; color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta-row {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 20px 5px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
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-pill:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||||
|
.status-pill:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.status-pill option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.score-select {
|
||||||
|
flex-shrink: 0; width: 58px;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 16px 5px 6px;
|
||||||
|
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: 7px;
|
||||||
|
}
|
||||||
|
.progress-block.clickable {
|
||||||
|
cursor: pointer; border-radius: var(--radius-sm);
|
||||||
|
padding: 4px 5px;
|
||||||
|
margin: 0 -5px;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.progress-block.clickable: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: 3px; 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-editor-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
|
.chapter-input {
|
||||||
|
width: 58px; background: var(--bg-surface);
|
||||||
|
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-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
||||||
|
.chapter-save-btn {
|
||||||
|
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-btn:hover { filter: brightness(1.15); }
|
||||||
|
.chapter-cancel-btn {
|
||||||
|
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-btn:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 200;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
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, 14px);
|
||||||
|
padding: var(--sp-6, 24px);
|
||||||
|
width: 320px; 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: 40px; height: 40px; border-radius: 50%;
|
||||||
|
background: var(--color-error-bg, rgba(200,50,50,0.12));
|
||||||
|
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.25));
|
||||||
|
color: var(--color-error, #e05252);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.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 var(--color-error-dim, rgba(200,50,50,0.3));
|
||||||
|
background: var(--color-error-bg, rgba(200,50,50,0.1));
|
||||||
|
color: var(--color-error, #e05252); cursor: pointer;
|
||||||
|
transition: filter var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.modal-confirm:hover { filter: brightness(1.2); background: var(--color-error-bg, rgba(200,50,50,0.18)); }
|
||||||
|
|
||||||
|
@keyframes modalIn {
|
||||||
|
from { opacity: 0; transform: scale(0.92) translateY(8px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script module>
|
||||||
|
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X } from "phosphor-svelte";
|
||||||
|
import { store, updateSettings } from "../../store/state.svelte";
|
||||||
|
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||||
|
import type { MangaPrefs } from "../../store/state.svelte";
|
||||||
|
let { mangaId, onClose }: {
|
||||||
|
mangaId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const mangaPrefs = $derived(
|
||||||
|
(store.settings.mangaPrefs?.[mangaId] ?? {}) as Partial<MangaPrefs>
|
||||||
|
);
|
||||||
|
|
||||||
|
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
|
||||||
|
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
|
||||||
|
updateSettings({
|
||||||
|
mangaPrefs: {
|
||||||
|
...store.settings.mangaPrefs,
|
||||||
|
[mangaId]: { ...(store.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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="Automation">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="modal-title">Automation</span>
|
||||||
|
<span class="modal-subtitle">Per-series rules</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={getPref("autoDownload")}
|
||||||
|
aria-label="Auto-download new chapters"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={getPref("autoDownload")}
|
||||||
|
onclick={() => setPref("autoDownload", !getPref("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={getPref("downloadAhead") === opt.value}
|
||||||
|
onclick={() => setPref("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={getPref("maxKeepChapters") === opt.value}
|
||||||
|
onclick={() => setPref("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={getPref("deleteOnRead")}
|
||||||
|
aria-label="Delete after reading"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={getPref("deleteOnRead")}
|
||||||
|
onclick={() => setPref("deleteOnRead", !getPref("deleteOnRead"))}
|
||||||
|
><span class="auto-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if getPref("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={getPref("deleteDelayHours") === opt.value}
|
||||||
|
onclick={() => setPref("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={getPref("pauseUpdates")}
|
||||||
|
aria-label="Pause updates"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={getPref("pauseUpdates")}
|
||||||
|
onclick={() => setPref("pauseUpdates", !getPref("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={getPref("refreshInterval") === opt.value}
|
||||||
|
onclick={() => setPref("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Section labels */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Rows — mirrors SeriesDetail auto-row */
|
||||||
|
.auto-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
.auto-row-align-start { align-items: flex-start; }
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* Toggle */
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* Chips */
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
|
||||||
|
import { store, removeMarker, updateMarker, openReader } from "../../store/state.svelte";
|
||||||
|
import type { MarkerEntry, MarkerColor } from "../../store/state.svelte";
|
||||||
|
import type { Chapter } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mangaId: number;
|
||||||
|
chapters: Chapter[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { mangaId, chapters, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const COLOR_HEX: Record<MarkerColor, string> = {
|
||||||
|
yellow: "#c4a94a",
|
||||||
|
red: "#c47a7a",
|
||||||
|
blue: "#7a9ec4",
|
||||||
|
green: "#7aab7a",
|
||||||
|
purple: "#a07ac4",
|
||||||
|
};
|
||||||
|
|
||||||
|
const markers = $derived(store.getMarkersForManga(mangaId));
|
||||||
|
|
||||||
|
const grouped = $derived.by(() => {
|
||||||
|
const map = new Map<number, MarkerEntry[]>();
|
||||||
|
for (const m of markers) {
|
||||||
|
if (!map.has(m.chapterId)) map.set(m.chapterId, []);
|
||||||
|
map.get(m.chapterId)!.push(m);
|
||||||
|
}
|
||||||
|
const entries = [...map.entries()].map(([chapterId, items]) => ({
|
||||||
|
chapterId,
|
||||||
|
chapterName: items[0].chapterName,
|
||||||
|
items: [...items].sort((a, b) => a.pageNumber - b.pageNumber),
|
||||||
|
}));
|
||||||
|
const chapterOrder = new Map(chapters.map((c, i) => [c.id, i]));
|
||||||
|
entries.sort((a, b) => (chapterOrder.get(a.chapterId) ?? 9999) - (chapterOrder.get(b.chapterId) ?? 9999));
|
||||||
|
return entries;
|
||||||
|
});
|
||||||
|
|
||||||
|
let editingId: string = $state("");
|
||||||
|
let editNote: string = $state("");
|
||||||
|
let editColor: MarkerColor = $state("yellow");
|
||||||
|
|
||||||
|
function startEdit(m: MarkerEntry) {
|
||||||
|
editingId = m.id;
|
||||||
|
editNote = m.note;
|
||||||
|
editColor = m.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitEdit() {
|
||||||
|
if (!editingId) return;
|
||||||
|
updateMarker(editingId, { note: editNote.trim(), color: editColor });
|
||||||
|
editingId = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToMarker(m: MarkerEntry) {
|
||||||
|
const chapter = chapters.find(c => c.id === m.chapterId);
|
||||||
|
if (!chapter) return;
|
||||||
|
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
openReader(chapter, chaptersAsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-title">
|
||||||
|
<MapPin size={13} weight="fill" />
|
||||||
|
<span>Markers</span>
|
||||||
|
{#if markers.length > 0}
|
||||||
|
<span class="count">{markers.length}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
{#if grouped.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<MapPin size={22} weight="light" style="color:var(--text-faint);opacity:0.4" />
|
||||||
|
<p>No markers yet</p>
|
||||||
|
<p class="empty-sub">Mark pages while reading with the marker button or keybind</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each grouped as group}
|
||||||
|
<div class="group">
|
||||||
|
<div class="group-header">
|
||||||
|
<span class="group-name">{group.chapterName}</span>
|
||||||
|
<span class="group-count">{group.items.length}</span>
|
||||||
|
</div>
|
||||||
|
{#each group.items as m (m.id)}
|
||||||
|
<div class="marker-row" class:editing={editingId === m.id}>
|
||||||
|
<div class="marker-dot" style="background:{COLOR_HEX[m.color]}"></div>
|
||||||
|
<div class="marker-body">
|
||||||
|
{#if editingId === m.id}
|
||||||
|
<div class="edit-wrap">
|
||||||
|
<div class="color-row">
|
||||||
|
{#each Object.entries(COLOR_HEX) as [c, hex]}
|
||||||
|
<button
|
||||||
|
class="color-swatch"
|
||||||
|
class:color-active={editColor === c}
|
||||||
|
style="background:{hex}"
|
||||||
|
onclick={() => editColor = c as MarkerColor}
|
||||||
|
title={c}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="edit-input"
|
||||||
|
rows={3}
|
||||||
|
bind:value={editNote}
|
||||||
|
placeholder="Add a note…"
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); } if (e.key === "Escape") editingId = ""; }}
|
||||||
|
></textarea>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button class="edit-save" onclick={commitEdit}><Check size={12} weight="bold" /> Save</button>
|
||||||
|
<button class="edit-cancel" onclick={() => editingId = ""}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="marker-jump" onclick={() => jumpToMarker(m)}>
|
||||||
|
<span class="page-label">p.{m.pageNumber}</span>
|
||||||
|
{#if m.note}
|
||||||
|
<span class="marker-note">{m.note}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="marker-note marker-note-empty">No note</span>
|
||||||
|
{/if}
|
||||||
|
<span class="marker-date">{formatDate(m.updatedAt ?? m.createdAt)}</span>
|
||||||
|
</button>
|
||||||
|
<div class="marker-actions">
|
||||||
|
<button class="marker-action-btn" onclick={() => startEdit(m)} title="Edit"><PencilSimple size={11} weight="light" /></button>
|
||||||
|
<button class="marker-action-btn danger" onclick={() => removeMarker(m.id)} title="Delete"><Trash size={11} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
|
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.panel-title { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
|
||||||
|
.count { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-full); font-size: var(--text-2xs); padding: 0 5px; color: var(--text-faint); }
|
||||||
|
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.panel-body { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.empty { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-8) var(--sp-4); text-align: center; }
|
||||||
|
.empty p { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.empty-sub { font-size: var(--text-2xs) !important; opacity: 0.7; max-width: 180px; line-height: var(--leading-snug); }
|
||||||
|
|
||||||
|
.group { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.group-header { display: flex; align-items: center; justify-content: space-between; padding: 6px var(--sp-2) 4px; }
|
||||||
|
.group-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.marker-row { display: flex; align-items: flex-start; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||||
|
.marker-row:hover { background: var(--bg-raised); }
|
||||||
|
.marker-row.editing { background: var(--bg-raised); }
|
||||||
|
.marker-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
|
||||||
|
|
||||||
|
.marker-body { flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: var(--sp-1); }
|
||||||
|
.marker-jump { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; background: none; border: none; padding: 0; cursor: pointer; }
|
||||||
|
.page-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||||||
|
.marker-note { font-size: var(--text-xs); color: var(--text-secondary); line-height: var(--leading-snug); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.marker-note-empty { color: var(--text-faint); font-style: italic; }
|
||||||
|
.marker-date { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
.marker-actions { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; opacity: 0; transition: opacity var(--t-fast); }
|
||||||
|
.marker-row:hover .marker-actions { opacity: 1; }
|
||||||
|
.marker-action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.marker-action-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
.marker-action-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
|
||||||
|
.edit-wrap { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.color-row { display: flex; gap: 5px; }
|
||||||
|
.color-swatch { width: 14px; height: 14px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
|
||||||
|
.color-swatch:hover { transform: scale(1.15); }
|
||||||
|
.color-active { border-color: var(--text-primary) !important; }
|
||||||
|
.edit-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base); }
|
||||||
|
.edit-input:focus { border-color: var(--border-focus); }
|
||||||
|
.edit-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.edit-save { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
|
||||||
|
.edit-save:hover { filter: brightness(1.15); }
|
||||||
|
.edit-cancel { padding: 4px 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); }
|
||||||
|
.edit-cancel:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { untrack } from "svelte";
|
||||||
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||||
|
import { store } from "../../store/state.svelte";
|
||||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -36,8 +39,47 @@
|
|||||||
let sources: Source[] = $state([]);
|
let sources: Source[] = $state([]);
|
||||||
let loadingSources = $state(true);
|
let loadingSources = $state(true);
|
||||||
let selectedSource: Source | null = $state(null);
|
let selectedSource: Source | null = $state(null);
|
||||||
const _initialTitle = manga.title;
|
|
||||||
let query = $state(_initialTitle);
|
// Lang filter: "en" first, then alphabetical
|
||||||
|
let selectedLang: string = $state("all");
|
||||||
|
let langStripEl: HTMLDivElement | undefined = $state();
|
||||||
|
const availableLangs = $derived.by(() => {
|
||||||
|
const langs = Array.from(new Set<string>(sources.map(s => s.lang))).sort();
|
||||||
|
const en = langs.indexOf("en");
|
||||||
|
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
|
||||||
|
return langs;
|
||||||
|
});
|
||||||
|
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||||
|
|
||||||
|
function scrollLangStrip(dir: -1 | 1) {
|
||||||
|
if (!langStripEl) return;
|
||||||
|
const strip = langStripEl;
|
||||||
|
const chips = Array.from(strip.children) as HTMLElement[];
|
||||||
|
const scrollLeft = strip.scrollLeft;
|
||||||
|
const viewEnd = scrollLeft + strip.clientWidth;
|
||||||
|
|
||||||
|
if (dir === 1) {
|
||||||
|
// Find first chip that is cut off or fully outside the right edge, scroll it flush left
|
||||||
|
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
|
||||||
|
if (next) strip.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
|
||||||
|
} else {
|
||||||
|
// Find last chip that is cut off or fully outside the left edge, scroll it flush right
|
||||||
|
const prev = [...chips].reverse().find(c => c.offsetLeft < scrollLeft - 2);
|
||||||
|
if (prev) strip.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - strip.clientWidth, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const visibleSources = $derived.by(() => {
|
||||||
|
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
|
||||||
|
const map = new Map<string, Source>();
|
||||||
|
for (const s of sources) {
|
||||||
|
const existing = map.get(s.name);
|
||||||
|
if (!existing) { map.set(s.name, s); continue; }
|
||||||
|
if (s.lang < existing.lang) map.set(s.name, s);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
|
||||||
|
let query = $state(untrack(() => manga.title));
|
||||||
let results: { manga: Manga; similarity: number }[] = $state([]);
|
let results: { manga: Manga; similarity: number }[] = $state([]);
|
||||||
let searching = $state(false);
|
let searching = $state(false);
|
||||||
let selectedMatch: Match | null = $state(null);
|
let selectedMatch: Match | null = $state(null);
|
||||||
@@ -52,7 +94,14 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then((d) => { sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id); })
|
.then((d) => {
|
||||||
|
const filtered = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id);
|
||||||
|
sources = filtered;
|
||||||
|
// Pre-select preferred lang if available and there are multiple
|
||||||
|
const prefLang = store?.settings?.preferredExtensionLang ?? "";
|
||||||
|
const langs = new Set(filtered.map(s => s.lang));
|
||||||
|
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
|
||||||
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { loadingSources = false; });
|
.finally(() => { loadingSources = false; });
|
||||||
|
|
||||||
@@ -178,21 +227,34 @@
|
|||||||
|
|
||||||
<!-- Step 1: Pick source -->
|
<!-- Step 1: Pick source -->
|
||||||
{#if step === "source"}
|
{#if step === "source"}
|
||||||
<div class="source-list">
|
{#if loadingSources}
|
||||||
{#if loadingSources}
|
<div class="centered">
|
||||||
<div class="centered">
|
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
</div>
|
||||||
|
{:else if sources.length === 0}
|
||||||
|
<div class="centered"><span class="hint">No other sources installed.</span></div>
|
||||||
|
{:else}
|
||||||
|
{#if hasMultipleLangs}
|
||||||
|
<div class="src-lang-bar">
|
||||||
|
<button class="src-lang-nav" onclick={() => scrollLangStrip(-1)}>‹</button>
|
||||||
|
<div class="src-lang-chips" bind:this={langStripEl}>
|
||||||
|
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === "all"} onclick={() => selectedLang = "all"}>All</button>
|
||||||
|
{#each availableLangs as lang}
|
||||||
|
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === lang} onclick={() => selectedLang = lang}>
|
||||||
|
{lang.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="src-lang-nav" onclick={() => scrollLangStrip(1)}>›</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if sources.length === 0}
|
{/if}
|
||||||
<div class="centered"><span class="hint">No other sources installed.</span></div>
|
<div class="source-list">
|
||||||
{:else}
|
{#each visibleSources as src}
|
||||||
{#each sources as src}
|
|
||||||
<button
|
<button
|
||||||
class="source-row"
|
class="source-row"
|
||||||
class:source-row-active={selectedSource?.id === src.id}
|
class:source-row-active={selectedSource?.id === src.id}
|
||||||
onclick={() => pickSource(src)}>
|
onclick={() => pickSource(src)}>
|
||||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon"
|
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
||||||
<div class="source-info">
|
<div class="source-info">
|
||||||
<span class="source-name">{src.displayName}</span>
|
<span class="source-name">{src.displayName}</span>
|
||||||
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||||
@@ -200,8 +262,8 @@
|
|||||||
<ArrowRight size={13} weight="light" class="source-arrow" />
|
<ArrowRight size={13} weight="light" class="source-arrow" />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<!-- Step 2: Search & pick match -->
|
<!-- Step 2: Search & pick match -->
|
||||||
{:else if step === "search"}
|
{:else if step === "search"}
|
||||||
@@ -210,8 +272,7 @@
|
|||||||
<!-- Source context pill -->
|
<!-- Source context pill -->
|
||||||
{#if selectedSource}
|
{#if selectedSource}
|
||||||
<div class="search-context">
|
<div class="search-context">
|
||||||
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon"
|
<Thumbnail src={selectedSource.iconUrl} alt="" class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
||||||
<span class="search-context-name">{selectedSource.displayName}</span>
|
<span class="search-context-name">{selectedSource.displayName}</span>
|
||||||
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
|
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,9 +281,9 @@
|
|||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
||||||
<input class="search-input" bind:value={query}
|
<input class="search-input" bind:value={query}
|
||||||
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||||
placeholder="Search title…" autofocus />
|
placeholder="Search title…" use:focusOnMount />
|
||||||
</div>
|
</div>
|
||||||
<button class="search-btn"
|
<button class="search-btn"
|
||||||
onclick={() => selectedSource && searchSource(selectedSource, query)}
|
onclick={() => selectedSource && searchSource(selectedSource, query)}
|
||||||
@@ -254,7 +315,7 @@
|
|||||||
onclick={() => selectMatch(m, similarity)}
|
onclick={() => selectMatch(m, similarity)}
|
||||||
disabled={loadingMatchId !== null}>
|
disabled={loadingMatchId !== null}>
|
||||||
<div class="result-cover-wrap">
|
<div class="result-cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="result-cover" />
|
||||||
</div>
|
</div>
|
||||||
<div class="result-info">
|
<div class="result-info">
|
||||||
<span class="result-title">{m.title}</span>
|
<span class="result-title">{m.title}</span>
|
||||||
@@ -288,7 +349,7 @@
|
|||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<div class="confirm-manga">
|
<div class="confirm-manga">
|
||||||
<div class="confirm-cover-wrap">
|
<div class="confirm-cover-wrap">
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="confirm-cover" />
|
<Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="confirm-cover" />
|
||||||
</div>
|
</div>
|
||||||
<p class="confirm-title">{manga.title}</p>
|
<p class="confirm-title">{manga.title}</p>
|
||||||
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
|
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
|
||||||
@@ -301,7 +362,7 @@
|
|||||||
|
|
||||||
<div class="confirm-manga">
|
<div class="confirm-manga">
|
||||||
<div class="confirm-cover-wrap">
|
<div class="confirm-cover-wrap">
|
||||||
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} class="confirm-cover" />
|
<Thumbnail src={selectedMatch.manga.thumbnailUrl} alt={selectedMatch.manga.title} class="confirm-cover" />
|
||||||
</div>
|
</div>
|
||||||
<p class="confirm-title">{selectedMatch.manga.title}</p>
|
<p class="confirm-title">{selectedMatch.manga.title}</p>
|
||||||
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
|
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
|
||||||
@@ -393,17 +454,29 @@
|
|||||||
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
.source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
|
.source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
.source-icon { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
:global(.source-icon) { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||||
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); }
|
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); }
|
||||||
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
||||||
|
|
||||||
|
/* Lang filter bar */
|
||||||
|
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.src-lang-nav:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; scroll-behavior: smooth; }
|
||||||
|
.src-lang-chip:last-child { margin-right: var(--sp-1); }
|
||||||
|
.src-lang-chips::-webkit-scrollbar { display: none; }
|
||||||
|
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.src-lang-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
.src-lang-chip-active:hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||||
|
|
||||||
/* Search step */
|
/* Search step */
|
||||||
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
|
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
|
||||||
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
|
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
|
||||||
.search-context-icon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
|
:global(.search-context-icon) { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
|
||||||
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); }
|
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); }
|
||||||
.search-context-change:hover { opacity: 0.75; }
|
.search-context-change:hover { opacity: 0.75; }
|
||||||
@@ -421,7 +494,7 @@
|
|||||||
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
||||||
.result-row:disabled { opacity: 0.5; cursor: default; }
|
.result-row:disabled { opacity: 0.5; cursor: default; }
|
||||||
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
.result-cover { width: 100%; height: 100%; object-fit: cover; }
|
:global(.result-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
.result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
||||||
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.result-meta { display: flex; align-items: center; gap: var(--sp-2); }
|
.result-meta { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
@@ -441,7 +514,7 @@
|
|||||||
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); }
|
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); }
|
||||||
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; }
|
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; }
|
||||||
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||||
.confirm-cover { width: 100%; height: 100%; object-fit: cover; }
|
:global(.confirm-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); }
|
.confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); }
|
||||||
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
||||||
.confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
.confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
@@ -471,3 +544,7 @@
|
|||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script module>
|
||||||
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
|
||||||
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
|
import {
|
||||||
|
GET_TRACKERS,
|
||||||
|
GET_MANGA_TRACK_RECORDS,
|
||||||
|
SEARCH_TRACKER,
|
||||||
|
BIND_TRACK,
|
||||||
|
UPDATE_TRACK,
|
||||||
|
UNBIND_TRACK,
|
||||||
|
FETCH_TRACK,
|
||||||
|
} from "../../lib/queries";
|
||||||
|
import { addToast } from "../../store/state.svelte";
|
||||||
|
import type { Tracker, TrackRecord, TrackSearch } from "../../lib/types";
|
||||||
|
|
||||||
|
let { mangaId, mangaTitle, onClose }: {
|
||||||
|
mangaId: number;
|
||||||
|
mangaTitle: string;
|
||||||
|
onClose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
type TabId = "records" | number;
|
||||||
|
|
||||||
|
let trackers: Tracker[] = $state([]);
|
||||||
|
let records: TrackRecord[] = $state([]);
|
||||||
|
let loading: boolean = $state(true);
|
||||||
|
let activeTab: TabId = $state("records");
|
||||||
|
|
||||||
|
let searchQuery: string = $state("");
|
||||||
|
let searchResults: TrackSearch[] = $state([]);
|
||||||
|
let searching: boolean = $state(false);
|
||||||
|
let searchInited: Set<number> = $state(new Set());
|
||||||
|
|
||||||
|
let binding: boolean = $state(false);
|
||||||
|
let updatingRecord: number | null = $state(null);
|
||||||
|
let syncing: number | null = $state(null);
|
||||||
|
let editingChapter: number | null = $state(null);
|
||||||
|
let chapterDraft: number = $state(0);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const [tRes, rRes] = await Promise.all([
|
||||||
|
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
|
||||||
|
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(
|
||||||
|
GET_MANGA_TRACK_RECORDS, { mangaId }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
trackers = tRes.trackers.nodes;
|
||||||
|
records = rRes.manga.trackRecords.nodes;
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { load(); });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const tab = activeTab;
|
||||||
|
if (typeof tab !== "number") return;
|
||||||
|
if (searchInited.has(tab)) return;
|
||||||
|
searchQuery = mangaTitle;
|
||||||
|
searchInited = new Set([...searchInited, tab]);
|
||||||
|
doSearch(tab, mangaTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
||||||
|
function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); }
|
||||||
|
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||||
|
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function onSearchInput() {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
if (typeof activeTab !== "number") return;
|
||||||
|
const tid = activeTab;
|
||||||
|
if (!searchQuery.trim()) { searchResults = []; return; }
|
||||||
|
searchTimer = setTimeout(() => doSearch(tid, searchQuery), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch(trackerId: number, query: string) {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
searching = true;
|
||||||
|
searchResults = [];
|
||||||
|
try {
|
||||||
|
const res = await gql<{ searchTracker: { trackSearches: TrackSearch[] } }>(
|
||||||
|
SEARCH_TRACKER, { trackerId, query: query.trim() }
|
||||||
|
);
|
||||||
|
searchResults = res.searchTracker.trackSearches;
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Search failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
searching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bind(result: TrackSearch) {
|
||||||
|
if (typeof activeTab !== "number") return;
|
||||||
|
binding = true;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
|
||||||
|
);
|
||||||
|
records = [...records.filter(r => r.trackerId !== activeTab), res.bindTrack.trackRecord];
|
||||||
|
addToast({ kind: "success", title: "Now tracking", body: result.title });
|
||||||
|
activeTab = "records";
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Failed to bind", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
binding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbind(record: TrackRecord) {
|
||||||
|
updatingRecord = record.id;
|
||||||
|
try {
|
||||||
|
await gql(UNBIND_TRACK, { recordId: record.id });
|
||||||
|
records = records.filter(r => r.id !== record.id);
|
||||||
|
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
updatingRecord = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(record: TrackRecord, status: number) {
|
||||||
|
updatingRecord = record.id;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, status }
|
||||||
|
);
|
||||||
|
patchRecord(res.updateTrack.trackRecord);
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
updatingRecord = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateScore(record: TrackRecord, scoreString: string) {
|
||||||
|
updatingRecord = record.id;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, scoreString }
|
||||||
|
);
|
||||||
|
patchRecord(res.updateTrack.trackRecord);
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
updatingRecord = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePrivate(record: TrackRecord) {
|
||||||
|
updatingRecord = record.id;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, private: !record.private }
|
||||||
|
);
|
||||||
|
patchRecord(res.updateTrack.trackRecord);
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
updatingRecord = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRecord(record: TrackRecord) {
|
||||||
|
syncing = record.id;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
FETCH_TRACK, { recordId: record.id }
|
||||||
|
);
|
||||||
|
patchRecord(res.fetchTrack.trackRecord);
|
||||||
|
addToast({ kind: "success", title: "Synced from tracker" });
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
syncing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
|
||||||
|
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChapterEditor(record: TrackRecord) {
|
||||||
|
editingChapter = record.id;
|
||||||
|
chapterDraft = record.lastChapterRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelChapterEditor() { editingChapter = null; }
|
||||||
|
|
||||||
|
async function submitChapter(record: TrackRecord) {
|
||||||
|
const val = Math.max(0, chapterDraft);
|
||||||
|
editingChapter = null;
|
||||||
|
if (val === record.lastChapterRead) return;
|
||||||
|
updatingRecord = record.id;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }
|
||||||
|
);
|
||||||
|
patchRecord(res.updateTrack.trackRecord);
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
updatingRecord = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(e) => e.key === "Escape" && onClose()} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div class="modal" role="dialog" aria-label="Tracking">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="modal-title">Tracking</span>
|
||||||
|
<span class="modal-subtitle">{mangaTitle}</span>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={onClose}><X size={15} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="state-body">
|
||||||
|
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
|
<span class="state-label">Loading…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if loggedInTrackers.length === 0}
|
||||||
|
<div class="state-body">
|
||||||
|
<p class="state-text">No trackers connected.</p>
|
||||||
|
<p class="state-hint">Go to Settings → Tracking to log in.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:tab-active={activeTab === "records"}
|
||||||
|
onclick={() => activeTab = "records"}
|
||||||
|
>
|
||||||
|
My List
|
||||||
|
{#if records.length > 0}
|
||||||
|
<span class="tab-badge">{records.length}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#each loggedInTrackers as t}
|
||||||
|
{@const rec = recordFor(t.id)}
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:tab-active={activeTab === t.id}
|
||||||
|
onclick={() => { activeTab = t.id; searchResults = []; }}
|
||||||
|
>
|
||||||
|
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
|
||||||
|
{t.name}
|
||||||
|
{#if rec}<span class="tab-dot"></span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab === "records"}
|
||||||
|
<div class="tab-body">
|
||||||
|
{#if records.length === 0}
|
||||||
|
<div class="state-body">
|
||||||
|
<p class="state-text">Not tracking this manga yet.</p>
|
||||||
|
<p class="state-hint">Click a tracker tab above to search and add it.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each records as record (record.id)}
|
||||||
|
{@const tracker = trackerFor(record.trackerId)}
|
||||||
|
{@const isBusy = updatingRecord === record.id}
|
||||||
|
<div class="record-card" class:record-busy={isBusy}>
|
||||||
|
|
||||||
|
<!-- Title row -->
|
||||||
|
<div class="record-head">
|
||||||
|
<div class="record-source">
|
||||||
|
{#if tracker}
|
||||||
|
<Thumbnail src={tracker.icon} alt={tracker.name} class="record-tracker-icon" />
|
||||||
|
{/if}
|
||||||
|
<span class="record-source-name">{tracker?.name ?? "Tracker"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="record-head-actions">
|
||||||
|
{#if tracker?.supportsPrivateTracking}
|
||||||
|
<button
|
||||||
|
class="record-icon-btn"
|
||||||
|
class:icon-active={record.private}
|
||||||
|
title={record.private ? "Private — click to make public" : "Public"}
|
||||||
|
disabled={isBusy}
|
||||||
|
onclick={() => togglePrivate(record)}
|
||||||
|
>
|
||||||
|
{#if record.private}<Lock size={11} weight="fill" />{:else}<LockOpen size={11} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
|
||||||
|
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
|
||||||
|
<X size={11} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Linked title -->
|
||||||
|
{#if record.remoteUrl}
|
||||||
|
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
|
||||||
|
{record.title} <ArrowSquareOut size={10} weight="light" />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="record-title-plain">{record.title}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Status + score row -->
|
||||||
|
<div class="record-selects">
|
||||||
|
<select
|
||||||
|
class="record-select record-select-status"
|
||||||
|
value={record.status}
|
||||||
|
disabled={isBusy}
|
||||||
|
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
||||||
|
>
|
||||||
|
{#each (tracker?.statuses ?? []) as s}
|
||||||
|
<option value={s.value}>{s.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
class="record-select record-select-score"
|
||||||
|
value={record.displayScore}
|
||||||
|
disabled={isBusy}
|
||||||
|
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
{#each (tracker?.scores ?? []) as s}
|
||||||
|
<option value={s}>★ {s}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapter progress -->
|
||||||
|
{#if editingChapter === record.id}
|
||||||
|
<div class="chapter-editor">
|
||||||
|
<div class="chapter-editor-top">
|
||||||
|
<span class="chapter-editor-label">Chapter read</span>
|
||||||
|
<div class="chapter-input-wrap">
|
||||||
|
<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") cancelChapterEditor(); }}
|
||||||
|
use:autoFocus
|
||||||
|
/>
|
||||||
|
{#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-editor-actions">
|
||||||
|
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
||||||
|
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="record-progress clickable" role="button" tabindex="0"
|
||||||
|
onclick={() => openChapterEditor(record)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
<div class="record-progress-header">
|
||||||
|
<span class="record-progress-label">
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
Ch. {record.lastChapterRead} / {record.totalChapters}
|
||||||
|
{:else if record.lastChapterRead > 0}
|
||||||
|
Ch. {record.lastChapterRead} read
|
||||||
|
{:else}
|
||||||
|
Set chapter…
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="edit-hint">Edit</span>
|
||||||
|
</div>
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
<div class="record-progress-track">
|
||||||
|
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{@const tracker = trackerFor(activeTab as number)}
|
||||||
|
{@const boundRecord = recordFor(activeTab as number)}
|
||||||
|
<div class="search-bar">
|
||||||
|
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search {tracker?.name}…"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
oninput={onSearchInput}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && doSearch(activeTab as number, searchQuery)}
|
||||||
|
use:autoFocus
|
||||||
|
/>
|
||||||
|
{#if searching}
|
||||||
|
<CircleNotch size={13} weight="light" class="anim-spin search-icon" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-results">
|
||||||
|
{#if searching && searchResults.length === 0}
|
||||||
|
<div class="state-body"><p class="state-hint">Searching…</p></div>
|
||||||
|
{:else if !searching && searchQuery.trim() && searchResults.length === 0}
|
||||||
|
<div class="state-body"><p class="state-text">No results for "{searchQuery}"</p></div>
|
||||||
|
{:else if !searchQuery.trim()}
|
||||||
|
<div class="state-body"><p class="state-hint">Type a title to search</p></div>
|
||||||
|
{:else}
|
||||||
|
{#each searchResults as result (result.trackerId + ":" + result.remoteId)}
|
||||||
|
{@const isBound = boundRecord?.remoteId === result.remoteId}
|
||||||
|
<button
|
||||||
|
class="result-row"
|
||||||
|
class:result-bound={isBound}
|
||||||
|
onclick={() => isBound ? unbind(boundRecord!) : bind(result)}
|
||||||
|
disabled={binding}
|
||||||
|
>
|
||||||
|
{#if result.coverUrl}
|
||||||
|
<img
|
||||||
|
src={result.coverUrl}
|
||||||
|
alt={result.title}
|
||||||
|
class="result-cover"
|
||||||
|
loading="lazy"
|
||||||
|
onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="result-cover result-cover-empty"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="result-info">
|
||||||
|
<span class="result-title">{result.title}</span>
|
||||||
|
<div class="result-meta">
|
||||||
|
{#if result.publishingType}<span class="result-tag">{result.publishingType}</span>{/if}
|
||||||
|
{#if result.publishingStatus}<span class="result-tag">{result.publishingStatus}</span>{/if}
|
||||||
|
{#if result.totalChapters > 0}<span class="result-tag">{result.totalChapters} ch</span>{/if}
|
||||||
|
</div>
|
||||||
|
{#if result.summary}
|
||||||
|
<p class="result-summary">{result.summary.slice(0,140)}{result.summary.length > 140 ? "…" : ""}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="result-action" class:result-action-on={isBound}>
|
||||||
|
{isBound ? "✓ Tracking" : "Track"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script module>
|
||||||
|
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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(560px, calc(100vw - 48px));
|
||||||
|
max-height: min(660px, calc(100vh - 80px));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.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; min-width: 0; }
|
||||||
|
.modal-title { font-size: var(--text-sm); 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-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.close-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); flex-shrink: 0; }
|
||||||
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
/* States */
|
||||||
|
.state-body { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-10) var(--sp-5); flex: 1; }
|
||||||
|
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.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); text-align: center; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
|
||||||
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
|
.tab {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2); position: relative;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 10px 10px 9px; color: var(--text-faint);
|
||||||
|
background: none; border: none; border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer; white-space: nowrap;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
|
||||||
|
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
|
||||||
|
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
|
||||||
|
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Records */
|
||||||
|
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.tab-body::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.record-card {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
transition: opacity var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.record-card:hover { border-color: var(--border-strong); }
|
||||||
|
.record-busy { opacity: 0.4; pointer-events: none; }
|
||||||
|
|
||||||
|
.record-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
|
||||||
|
.record-source { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
:global(.record-tracker-icon) { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.75; }
|
||||||
|
.record-source-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.record-head-actions { display: flex; align-items: center; gap: 2px; }
|
||||||
|
|
||||||
|
.record-title { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); text-decoration: none; line-height: var(--leading-snug); transition: color var(--t-base); }
|
||||||
|
.record-title:hover { color: var(--accent-fg); }
|
||||||
|
.record-title-plain { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: var(--leading-snug); }
|
||||||
|
|
||||||
|
.record-selects { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
.record-select {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 24px 5px 10px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-surface);
|
||||||
|
color: var(--text-muted); outline: none; cursor: pointer; flex: 1; min-width: 0;
|
||||||
|
appearance: none; -webkit-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 8px center;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.record-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||||
|
.record-select:focus { border-color: var(--accent-dim); color: var(--text-secondary); }
|
||||||
|
.record-select:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
.record-select-score { flex: 0 0 auto; min-width: 80px; }
|
||||||
|
.record-select-status { flex: 1; }
|
||||||
|
|
||||||
|
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||||
|
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
.record-icon-btn.icon-active { color: var(--accent-fg); }
|
||||||
|
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
.record-progress { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 4px 6px; margin: -4px -6px; transition: background var(--t-fast); }
|
||||||
|
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
||||||
|
.record-progress-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.edit-hint { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); opacity: 0; transition: opacity var(--t-fast); }
|
||||||
|
.record-progress.clickable:hover .edit-hint { opacity: 0.6; }
|
||||||
|
.record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
|
||||||
|
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||||
|
|
||||||
|
/* Chapter editor */
|
||||||
|
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); 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-3); }
|
||||||
|
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.chapter-input { width: 64px; 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-sm); color: var(--text-primary); outline: none; text-align: center; }
|
||||||
|
.chapter-input:focus { border-color: var(--accent); }
|
||||||
|
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||||
|
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
|
||||||
|
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; 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-btn:hover { filter: brightness(1.15); }
|
||||||
|
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
|
||||||
|
.chapter-cancel-btn:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; background: var(--bg-surface); }
|
||||||
|
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
.search-input::placeholder { color: var(--text-faint); }
|
||||||
|
.search-results { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
||||||
|
.search-results::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* Results */
|
||||||
|
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||||
|
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
||||||
|
.result-row:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
|
||||||
|
.result-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.result-cover-empty { background: var(--bg-raised); }
|
||||||
|
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
|
||||||
|
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
|
||||||
|
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
||||||
|
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
|
||||||
|
.result-action { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
|
||||||
|
|
||||||
|
@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,575 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
|
||||||
|
import {
|
||||||
|
store, updateSettings, saveCustomTheme, deleteCustomTheme,
|
||||||
|
type CustomTheme, type ThemeTokens, DEFAULT_THEME_TOKENS,
|
||||||
|
} from "../../store/state.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editingId?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { editingId = $bindable(null), onClose }: Props = $props();
|
||||||
|
|
||||||
|
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
|
||||||
|
{
|
||||||
|
label: "Backgrounds",
|
||||||
|
tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Borders",
|
||||||
|
tokens: ["border-dim", "border-base", "border-strong", "border-focus"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Text",
|
||||||
|
tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Accent",
|
||||||
|
tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Semantic",
|
||||||
|
tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
|
||||||
|
"bg-void": "Void (deepest bg)",
|
||||||
|
"bg-base": "Base",
|
||||||
|
"bg-surface": "Surface",
|
||||||
|
"bg-raised": "Raised",
|
||||||
|
"bg-overlay": "Overlay",
|
||||||
|
"bg-subtle": "Subtle",
|
||||||
|
"border-dim": "Dim border",
|
||||||
|
"border-base": "Base border",
|
||||||
|
"border-strong": "Strong border",
|
||||||
|
"border-focus": "Focus ring",
|
||||||
|
"text-primary": "Primary text",
|
||||||
|
"text-secondary": "Secondary text",
|
||||||
|
"text-muted": "Muted text",
|
||||||
|
"text-faint": "Faint text",
|
||||||
|
"text-disabled": "Disabled text",
|
||||||
|
"accent": "Accent",
|
||||||
|
"accent-dim": "Accent dim",
|
||||||
|
"accent-muted": "Accent muted",
|
||||||
|
"accent-fg": "Accent foreground",
|
||||||
|
"accent-bright": "Accent bright",
|
||||||
|
"color-error": "Error",
|
||||||
|
"color-error-bg": "Error background",
|
||||||
|
"color-success": "Success",
|
||||||
|
"color-info": "Info",
|
||||||
|
"color-info-bg": "Info background",
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadInitial(): { name: string; tokens: ThemeTokens } {
|
||||||
|
if (editingId) {
|
||||||
|
const existing = store.settings.customThemes.find(t => t.id === editingId);
|
||||||
|
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
|
||||||
|
}
|
||||||
|
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = loadInitial();
|
||||||
|
let themeName: string = $state(initial.name);
|
||||||
|
let tokens: ThemeTokens = $state(initial.tokens);
|
||||||
|
let saveStatus: "idle" | "saved" = $state("idle");
|
||||||
|
let importError: string | null = $state(null);
|
||||||
|
|
||||||
|
function toCssVars(t: ThemeTokens): string {
|
||||||
|
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const name = themeName.trim() || "Untitled Theme";
|
||||||
|
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const theme: CustomTheme = { id, name, tokens: { ...tokens } };
|
||||||
|
saveCustomTheme(theme);
|
||||||
|
updateSettings({ theme: id });
|
||||||
|
editingId = id;
|
||||||
|
saveStatus = "saved";
|
||||||
|
setTimeout(() => (saveStatus = "idle"), 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!editingId) { onClose(); return; }
|
||||||
|
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
|
||||||
|
deleteCustomTheme(editingId);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
const data: CustomTheme = {
|
||||||
|
id: editingId ?? "custom:export",
|
||||||
|
name: themeName.trim() || "Untitled Theme",
|
||||||
|
tokens: { ...tokens },
|
||||||
|
};
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImport() {
|
||||||
|
const inp = document.createElement("input");
|
||||||
|
inp.type = "file";
|
||||||
|
inp.accept = ".json";
|
||||||
|
inp.onchange = async () => {
|
||||||
|
const file = inp.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
|
||||||
|
if (typeof data.name === "string") themeName = data.name;
|
||||||
|
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
|
||||||
|
importError = null;
|
||||||
|
} catch (e: any) {
|
||||||
|
importError = e.message ?? "Could not parse theme file";
|
||||||
|
setTimeout(() => (importError = null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
inp.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToDefaults() {
|
||||||
|
tokens = { ...DEFAULT_THEME_TOKENS };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div class="te-backdrop" role="presentation" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
|
||||||
|
<div
|
||||||
|
class="te-shell"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Theme editor"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
|
||||||
|
<header class="te-header">
|
||||||
|
<div class="te-header-left">
|
||||||
|
<button class="te-icon-btn" onclick={onClose} title="Close editor">
|
||||||
|
<ArrowLeft size={14} weight="bold" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
bind:value={themeName}
|
||||||
|
class="te-name-input"
|
||||||
|
placeholder="Theme name"
|
||||||
|
maxlength={40}
|
||||||
|
spellcheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="te-header-actions">
|
||||||
|
{#if importError}
|
||||||
|
<span class="te-import-err">{importError}</span>
|
||||||
|
{/if}
|
||||||
|
<button class="te-action-btn" onclick={handleImport} title="Import from JSON">
|
||||||
|
<UploadSimple size={13} />
|
||||||
|
<span>Import</span>
|
||||||
|
</button>
|
||||||
|
<button class="te-action-btn" onclick={handleExport} title="Export as JSON">
|
||||||
|
<DownloadSimple size={13} />
|
||||||
|
<span>Export</span>
|
||||||
|
</button>
|
||||||
|
<button class="te-action-btn te-ghost" onclick={resetToDefaults} title="Reset all to dark defaults">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
{#if editingId}
|
||||||
|
<button class="te-action-btn te-danger" onclick={handleDelete} title="Delete theme">
|
||||||
|
<Trash size={13} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="te-save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
|
||||||
|
<FloppyDisk size={13} />
|
||||||
|
<span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
|
||||||
|
</button>
|
||||||
|
<button class="te-icon-btn" onclick={onClose} title="Close">
|
||||||
|
<X size={14} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="te-body">
|
||||||
|
|
||||||
|
<aside class="te-preview-pane">
|
||||||
|
<div class="te-pane-label">Live Preview</div>
|
||||||
|
|
||||||
|
<div class="te-preview-ui" style={toCssVars(tokens)}>
|
||||||
|
<div class="prv-sidebar">
|
||||||
|
{#each [true, false, false, false] as active}
|
||||||
|
<div class="prv-sb-dot" class:active></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="prv-main">
|
||||||
|
<div class="prv-titlebar">
|
||||||
|
<div class="prv-win-dots">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<div class="prv-win-title">Moku</div>
|
||||||
|
</div>
|
||||||
|
<div class="prv-content">
|
||||||
|
<div class="prv-row">
|
||||||
|
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
|
||||||
|
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="prv-grid">
|
||||||
|
{#each Array(6) as _, i}
|
||||||
|
<div class="prv-card" class:active-card={i === 0}>
|
||||||
|
<div class="prv-cover"></div>
|
||||||
|
<div class="prv-card-line"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="prv-reader">
|
||||||
|
<div class="prv-page"></div>
|
||||||
|
</div>
|
||||||
|
<div class="prv-toast">
|
||||||
|
<div class="prv-toast-dot"></div>
|
||||||
|
<div class="prv-toast-lines">
|
||||||
|
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
|
||||||
|
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="te-swatches" style={toCssVars(tokens)}>
|
||||||
|
{#each [
|
||||||
|
["bg-base","bg-base"],["bg-surface","bg-surface"],
|
||||||
|
["accent","accent"],["accent-fg","accent-fg"],
|
||||||
|
["text-primary","text-primary"],["text-muted","text-muted"],
|
||||||
|
["color-error","color-error"],
|
||||||
|
] as [varName, label]}
|
||||||
|
<div
|
||||||
|
class="te-swatch"
|
||||||
|
style="background: var(--{varName})"
|
||||||
|
title={label}
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="te-editor-pane">
|
||||||
|
{#each TOKEN_GROUPS as group}
|
||||||
|
<div class="te-group">
|
||||||
|
<div class="te-group-label">{group.label}</div>
|
||||||
|
<div class="te-token-list">
|
||||||
|
{#each group.tokens as token}
|
||||||
|
<div class="te-token-row">
|
||||||
|
<label class="te-color-swatch" style="background: {tokens[token]}" title="Pick colour">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="te-color-picker"
|
||||||
|
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0,7)}
|
||||||
|
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span class="te-token-name">{TOKEN_LABELS[token]}</span>
|
||||||
|
<span class="te-token-key">{token}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="te-hex-input"
|
||||||
|
value={tokens[token]}
|
||||||
|
spellcheck={false}
|
||||||
|
oninput={(e) => {
|
||||||
|
const v = (e.target as HTMLInputElement).value.trim();
|
||||||
|
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
|
||||||
|
}}
|
||||||
|
onblur={(e) => {
|
||||||
|
const v = (e.target as HTMLInputElement).value.trim();
|
||||||
|
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
||||||
|
(e.target as HTMLInputElement).value = tokens[token];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.te-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: teBackdropIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.te-shell {
|
||||||
|
width: calc(100% - 48px);
|
||||||
|
max-width: 1100px;
|
||||||
|
height: calc(100% - 48px);
|
||||||
|
max-height: 760px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 10px;
|
||||||
|
animation: teShellIn 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@keyframes teShellIn {
|
||||||
|
from { transform: translateY(10px) scale(0.99); opacity: 0; }
|
||||||
|
to { transform: translateY(0) scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 12px; padding: 0 16px; height: 46px;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-header-left {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-icon-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 26px; height: 26px; border-radius: 5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.1s, background 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.te-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.te-name-input {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
background: none; border: none; outline: none;
|
||||||
|
font-family: var(--font-sans); font-size: 13px; font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
padding: 3px 0;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
.te-name-input:focus { border-color: var(--border-focus); }
|
||||||
|
.te-name-input::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.te-header-actions {
|
||||||
|
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-import-err {
|
||||||
|
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em;
|
||||||
|
color: var(--color-error); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-action-btn {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
|
||||||
|
padding: 4px 10px; border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: none; color: var(--text-muted);
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color 0.1s, border-color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
.te-action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.te-ghost { border-color: transparent; }
|
||||||
|
.te-ghost:hover { border-color: var(--border-dim); }
|
||||||
|
|
||||||
|
.te-danger { color: var(--color-error); border-color: transparent; }
|
||||||
|
.te-danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
|
||||||
|
|
||||||
|
.te-save-btn {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
|
||||||
|
padding: 5px 14px; border-radius: 4px;
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
background: var(--accent-muted); color: var(--accent-fg);
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: filter 0.1s, background 0.12s;
|
||||||
|
}
|
||||||
|
.te-save-btn:hover { filter: brightness(1.12); }
|
||||||
|
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
|
||||||
|
|
||||||
|
.te-preview-pane {
|
||||||
|
width: 260px; flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-void);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: 16px; gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-pane-label {
|
||||||
|
font-family: var(--font-ui); font-size: 10px;
|
||||||
|
letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-preview-ui {
|
||||||
|
flex: 1; min-height: 0;
|
||||||
|
border-radius: 8px; overflow: hidden;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
display: flex; background: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prv-sidebar {
|
||||||
|
width: 34px; flex-shrink: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; padding: 12px 0; gap: 9px;
|
||||||
|
}
|
||||||
|
.prv-sb-dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
background: var(--text-faint); opacity: 0.4;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
|
||||||
|
|
||||||
|
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
|
.prv-titlebar {
|
||||||
|
height: 26px; flex-shrink: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
display: flex; align-items: center; padding: 0 8px; gap: 7px;
|
||||||
|
}
|
||||||
|
.prv-win-dots { display: flex; gap: 4px; }
|
||||||
|
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
|
||||||
|
.prv-win-title { font-family: var(--font-ui); font-size: 9px; letter-spacing: 0.1em; color: var(--text-faint); }
|
||||||
|
|
||||||
|
.prv-content {
|
||||||
|
flex: 1; overflow: hidden;
|
||||||
|
padding: 8px; display: flex; flex-direction: column; gap: 7px;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prv-row { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||||
|
.prv-bar { height: 3px; border-radius: 2px; }
|
||||||
|
|
||||||
|
.prv-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.prv-card {
|
||||||
|
border-radius: 4px; border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised); overflow: hidden;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.prv-card.active-card { border-color: var(--accent); }
|
||||||
|
.prv-cover { height: 34px; background: var(--bg-overlay); }
|
||||||
|
.prv-card-line { height: 3px; margin: 4px 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
|
||||||
|
|
||||||
|
.prv-reader {
|
||||||
|
flex: 1; min-height: 0;
|
||||||
|
border-radius: 4px; border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
|
||||||
|
|
||||||
|
.prv-toast {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 8px; border-radius: 5px;
|
||||||
|
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
|
||||||
|
}
|
||||||
|
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||||
|
.prv-toast-lines { flex: 1; }
|
||||||
|
|
||||||
|
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
|
||||||
|
.te-swatch {
|
||||||
|
width: 22px; height: 22px; border-radius: 4px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
|
flex-shrink: 0; cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-editor-pane {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex; flex-direction: column; gap: 22px;
|
||||||
|
}
|
||||||
|
.te-editor-pane::-webkit-scrollbar { width: 4px; }
|
||||||
|
.te-editor-pane::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.te-editor-pane::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-strong); border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-group { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
.te-group-label {
|
||||||
|
font-family: var(--font-ui); font-size: 10px;
|
||||||
|
letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding-bottom: 7px; margin-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-token-list { display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
|
||||||
|
.te-token-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 5px 8px; border-radius: 5px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.te-token-row:hover { background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.te-color-swatch {
|
||||||
|
width: 36px; height: 18px; border-radius: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.te-color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
|
||||||
|
|
||||||
|
.te-color-picker {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0; border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-token-name {
|
||||||
|
flex: 1; font-size: 12px; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-token-key {
|
||||||
|
font-family: var(--font-ui); font-size: 10px;
|
||||||
|
letter-spacing: 0.05em; color: var(--text-faint);
|
||||||
|
flex-shrink: 0; min-width: 0;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-hex-input {
|
||||||
|
width: 82px; flex-shrink: 0;
|
||||||
|
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: 3px; padding: 3px 7px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
.te-hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
|
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { GET_ALL_MANGA } from "../../lib/queries";
|
import { GET_ALL_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { store, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
import { store, openReader, addToast, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
let manga: Manga | null = $state(null);
|
||||||
let chapters: Chapter[] = $state([]);
|
let chapters: Chapter[] = $state([]);
|
||||||
@@ -17,6 +18,9 @@
|
|||||||
let folderOpen = $state(false);
|
let folderOpen = $state(false);
|
||||||
let newFolderName = $state("");
|
let newFolderName = $state("");
|
||||||
let creatingFolder = $state(false);
|
let creatingFolder = $state(false);
|
||||||
|
let allCategories: Category[] = $state([]);
|
||||||
|
let mangaCategories: Category[] = $state([]);
|
||||||
|
let catsLoading: boolean = $state(false);
|
||||||
let queueingAll = $state(false);
|
let queueingAll = $state(false);
|
||||||
let fetchError: string|null = $state(null);
|
let fetchError: string|null = $state(null);
|
||||||
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
||||||
@@ -79,7 +83,7 @@
|
|||||||
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
|
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
|
||||||
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
|
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
|
||||||
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
|
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
|
||||||
const assignedFolders = $derived(store.previewManga ? store.settings.folders.filter((f) => f.mangaIds.includes(store.previewManga!.id)) : []);
|
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
||||||
|
|
||||||
const continueChapter = $derived.by(() => {
|
const continueChapter = $derived.by(() => {
|
||||||
if (!chapters.length) return null;
|
if (!chapters.length) return null;
|
||||||
@@ -90,7 +94,7 @@
|
|||||||
return { ch: chapters[0], label: "Read again" };
|
return { ch: chapters[0], label: "Read again" };
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
|
$effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
|
||||||
|
|
||||||
async function load(id: number) {
|
async function load(id: number) {
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
detailAbort?.abort(); chapterAbort?.abort();
|
||||||
@@ -171,11 +175,55 @@
|
|||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFolderCreate() {
|
function loadCategories(mangaId: number) {
|
||||||
|
catsLoading = true;
|
||||||
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then(d => {
|
||||||
|
allCategories = d.categories.nodes.filter(c => c.id !== 0);
|
||||||
|
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => { catsLoading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||||
|
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||||
|
// Sync local mangaCategories state after the mutation
|
||||||
|
if (chaps.length) {
|
||||||
|
const allRead = chaps.every(c => c.isRead);
|
||||||
|
const completed = allCategories.find(c => c.name === "Completed");
|
||||||
|
if (completed) {
|
||||||
|
const inCompleted = mangaCategories.some(c => c.id === completed.id);
|
||||||
|
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
|
||||||
|
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCategory(cat: Category) {
|
||||||
|
if (!store.previewManga) return;
|
||||||
|
const mangaId = store.previewManga.id;
|
||||||
|
const inCat = mangaCategories.some(c => c.id === cat.id);
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, {
|
||||||
|
mangaId,
|
||||||
|
addTo: inCat ? [] : [cat.id],
|
||||||
|
removeFrom: inCat ? [cat.id] : [],
|
||||||
|
}).catch(console.error);
|
||||||
|
mangaCategories = inCat
|
||||||
|
? mangaCategories.filter(c => c.id !== cat.id)
|
||||||
|
: [...mangaCategories, cat];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFolderCreate() {
|
||||||
const name = newFolderName.trim();
|
const name = newFolderName.trim();
|
||||||
if (!name || !store.previewManga) return;
|
if (!name || !store.previewManga) return;
|
||||||
const id = addFolder(name);
|
try {
|
||||||
assignMangaToFolder(id, store.previewManga.id);
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||||
|
const cat = res.createCategory.category;
|
||||||
|
allCategories = [...allCategories, cat];
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.previewManga.id, addTo: [cat.id], removeFrom: [] });
|
||||||
|
mangaCategories = [...mangaCategories, cat];
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
newFolderName = ""; creatingFolder = false;
|
newFolderName = ""; creatingFolder = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +249,7 @@
|
|||||||
|
|
||||||
<div class="cover-col">
|
<div class="cover-col">
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
<Thumbnail src={store.previewManga.thumbnailUrl} alt={displayManga?.title} class="cover" />
|
||||||
{#if loadingDetail}
|
{#if loadingDetail}
|
||||||
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -225,12 +273,15 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if folderOpen}
|
{#if folderOpen}
|
||||||
<div class="folder-menu">
|
<div class="folder-menu">
|
||||||
{#if store.settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
|
{#if catsLoading}
|
||||||
{#each store.settings.folders as f}
|
<p class="folder-empty">Loading…</p>
|
||||||
{@const isIn = store.previewManga ? f.mangaIds.includes(store.previewManga.id) : false}
|
{:else if allCategories.length === 0 && !creatingFolder}
|
||||||
<button class="folder-item" class:folder-item-on={isIn}
|
<p class="folder-empty">No folders yet</p>
|
||||||
onclick={() => store.previewManga && (isIn ? removeMangaFromFolder(f.id, store.previewManga.id) : assignMangaToFolder(f.id, store.previewManga.id))}>
|
{/if}
|
||||||
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
|
{#each allCategories as cat}
|
||||||
|
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
|
||||||
|
<button class="folder-item" class:folder-item-on={isIn} onclick={() => toggleCategory(cat)}>
|
||||||
|
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{cat.name}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="folder-divider"></div>
|
<div class="folder-divider"></div>
|
||||||
@@ -306,7 +357,7 @@
|
|||||||
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
|
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if continueChapter}
|
{#if continueChapter}
|
||||||
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}>
|
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters, displayManga); close(); }}>
|
||||||
<Play size={12} weight="fill" />{continueChapter.label}
|
<Play size={12} weight="fill" />{continueChapter.label}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -343,19 +394,18 @@
|
|||||||
|
|
||||||
{#if !loadingDetail}
|
{#if !loadingDetail}
|
||||||
<div class="meta-table">
|
<div class="meta-table">
|
||||||
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
|
<div class="meta-grid">
|
||||||
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if}
|
<div class="meta-col">
|
||||||
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if}
|
<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel ?? "N/A"}</span></div>
|
||||||
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if}
|
<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span></div>
|
||||||
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if}
|
<div class="meta-row"><span class="meta-key">Link</span>{#if displayManga?.realUrl}<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a>{:else}<span class="meta-val">N/A</span>{/if}</div>
|
||||||
{#if !loadingChapters && firstUpload && lastUpload}
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="meta-key">Published</span>
|
|
||||||
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="meta-col">
|
||||||
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if}
|
<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
|
||||||
{#if displayManga?.realUrl}<div class="meta-row"><span class="meta-key">Link</span><a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a></div>{/if}
|
<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga?.artist && displayManga.artist !== displayManga.author ? displayManga.artist : (displayManga?.author ?? "N/A")}</span></div>
|
||||||
|
<div class="meta-row"><span class="meta-key">Scanlator</span><span class="meta-val">{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -388,7 +438,7 @@
|
|||||||
{#each linkPickerResults as m (m.id)}
|
{#each linkPickerResults as m (m.id)}
|
||||||
{@const isLinked = linkedIds.includes(m.id)}
|
{@const isLinked = linkedIds.includes(m.id)}
|
||||||
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
|
||||||
<div class="link-info">
|
<div class="link-info">
|
||||||
<span class="link-manga-title">{m.title}</span>
|
<span class="link-manga-title">{m.title}</span>
|
||||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
||||||
@@ -413,7 +463,7 @@
|
|||||||
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
||||||
.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-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-wrap { position: relative; width: 100%; }
|
||||||
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
:global(.cover) { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
||||||
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
||||||
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
.cover-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; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.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; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
@@ -478,9 +528,11 @@
|
|||||||
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
|
||||||
|
.meta-col { display: flex; flex-direction: column; }
|
||||||
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
.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: 56px; flex-shrink: 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: 56px; flex-shrink: 0; }
|
||||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
|
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
||||||
.meta-link:hover { opacity: 0.75; }
|
.meta-link:hover { opacity: 0.75; }
|
||||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
|
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
|
||||||
@@ -497,7 +549,7 @@
|
|||||||
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||||
.link-row:hover { background: var(--bg-raised); }
|
.link-row:hover { background: var(--bg-raised); }
|
||||||
.link-row-linked { background: var(--accent-muted) !important; }
|
.link-row-linked { background: var(--accent-muted) !important; }
|
||||||
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
:global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { store, addFolder, assignMangaToFolder, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import type { Manga } from "../../lib/types";
|
import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||||
|
import type { Manga, Category } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
|
||||||
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
||||||
|
|
||||||
let mangas: Manga[] = [];
|
let mangas: Manga[] = $state([]);
|
||||||
let loading = true;
|
let loading = $state(true);
|
||||||
let page = 1;
|
let page = $state(1);
|
||||||
let hasNextPage = false;
|
let hasNextPage = $state(false);
|
||||||
let browseType: BrowseType = "POPULAR";
|
let browseType: BrowseType = $state("POPULAR");
|
||||||
let search = "";
|
let search = $state("");
|
||||||
let searchInput = "";
|
let searchInput = $state("");
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
|
let categories: Category[] = $state([]);
|
||||||
|
let catsLoaded = false;
|
||||||
|
|
||||||
async function fetchMangas(type: BrowseType, p: number, q: string) {
|
async function fetchMangas(type: BrowseType, p: number, q: string) {
|
||||||
if (!$store.activeSource) return;
|
if (!store.activeSource) return;
|
||||||
loading = true; mangas = [];
|
loading = true; mangas = [];
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
FETCH_SOURCE_MANGA, { source: $store.activeSource.id, type, page: p, query: q || null }
|
FETCH_SOURCE_MANGA, { source: store.activeSource.id, type, page: p, query: q || null }
|
||||||
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
|
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => loading = false);
|
.finally(() => loading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($store.activeSource) fetchMangas(browseType, page, search);
|
$effect(() => { if (store.activeSource) fetchMangas(browseType, page, search); });
|
||||||
|
|
||||||
function submitSearch() {
|
function submitSearch() {
|
||||||
search = searchInput.trim();
|
search = searchInput.trim();
|
||||||
@@ -40,38 +43,58 @@
|
|||||||
browseType = mode; search = ""; searchInput = ""; page = 1;
|
browseType = mode; search = ""; searchInput = ""; page = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
|
if (!catsLoaded) {
|
||||||
|
catsLoaded = true;
|
||||||
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
return [
|
return [
|
||||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
|
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
|
||||||
.catch(console.error) },
|
.catch(console.error) },
|
||||||
...($store.settings.folders.length > 0 ? [
|
...(categories.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...$store.settings.folders.map((f): MenuEntry => ({
|
...categories.map((cat): MenuEntry => ({
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||||
})),
|
})),
|
||||||
] : []),
|
] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
{ label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() }).catch(console.error);
|
||||||
|
if (res) {
|
||||||
|
const cat = res.createCategory.category;
|
||||||
|
categories = [...categories, cat];
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||||
|
}
|
||||||
|
}},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $store.activeSource}
|
{#if store.activeSource}
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<button class="back" on:click={() => store.activeSource.set(null)}>
|
<button class="back" onclick={() => setActiveSource(null)}>
|
||||||
<ArrowLeft size={13} weight="light" /><span>Sources</span>
|
<ArrowLeft size={13} weight="light" /><span>Sources</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="source-name">{$store.activeSource.displayName}</span>
|
<span class="source-name">{store.activeSource.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
|
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
|
||||||
<button class="tab" class:active={browseType === mode && !search} on:click={() => setMode(mode)}>
|
<button class="tab" class:active={browseType === mode && !search} onclick={() => setMode(mode)}>
|
||||||
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -80,7 +103,7 @@
|
|||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
<input class="search" placeholder="Search source…" bind:value={searchInput}
|
<input class="search" placeholder="Search source…" bind:value={searchInput}
|
||||||
on:keydown={(e) => e.key === "Enter" && submitSearch()} />
|
onkeydown={(e) => e.key === "Enter" && submitSearch()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,10 +118,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each mangas as m (m.id)}
|
{#each mangas as m (m.id)}
|
||||||
<button class="card" on:click={() => { store.activeManga.set(m); store.navPage.set("library"); }}
|
<button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
|
||||||
on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
oncontextmenu={(e) => openCtx(e, m)}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||||
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="title">{m.title}</p>
|
<p class="title">{m.title}</p>
|
||||||
@@ -109,11 +132,11 @@
|
|||||||
|
|
||||||
{#if !loading && (page > 1 || hasNextPage)}
|
{#if !loading && (page > 1 || hasNextPage)}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<button class="page-btn" on:click={() => page = Math.max(1, page - 1)} disabled={page === 1}>
|
<button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
|
||||||
<Prev size={13} weight="light" /> Prev
|
<Prev size={13} weight="light" /> Prev
|
||||||
</button>
|
</button>
|
||||||
<span class="page-num">{page}</span>
|
<span class="page-num">{page}</span>
|
||||||
<button class="page-btn" on:click={() => page++} disabled={!hasNextPage}>
|
<button class="page-btn" onclick={() => page++} disabled={!hasNextPage}>
|
||||||
Next <Next size={13} weight="light" />
|
Next <Next size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,10 +166,10 @@
|
|||||||
.search:focus { border-color: var(--border-strong); }
|
.search:focus { border-color: var(--border-strong); }
|
||||||
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
.card:hover :global(.cover) { filter: brightness(1.06); }
|
||||||
.card:hover .title { color: var(--text-primary); }
|
.card:hover .title { color: var(--text-primary); }
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||||
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||||
.card-skeleton { padding: 0; }
|
.card-skeleton { padding: 0; }
|
||||||
@@ -158,4 +181,5 @@
|
|||||||
.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-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
|
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
|
||||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_SOURCES } from "../../lib/queries";
|
import { GET_SOURCES } from "../../lib/queries";
|
||||||
import { store } from "../../store/state.svelte";
|
import { store } from "../../store/state.svelte";
|
||||||
import type { Source } from "../../lib/types";
|
import type { Source } from "../../lib/types";
|
||||||
@@ -11,7 +13,7 @@
|
|||||||
let search = $state("");
|
let search = $state("");
|
||||||
let expanded = $state(new Set<string>());
|
let expanded = $state(new Set<string>());
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then((d) => { sources = d.sources.nodes; })
|
.then((d) => { sources = d.sources.nodes; })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
<div class="lang-row">
|
<div class="lang-row">
|
||||||
{#each langs as l}
|
{#each langs as l}
|
||||||
<button class="lang-btn" class:active={lang === l} onclick={() => lang = l}>
|
<button class="lang-btn" class:active={lang === l} onclick={() => lang = l}>
|
||||||
@@ -72,8 +75,7 @@
|
|||||||
{@const open = expanded.has(g.name)}
|
{@const open = expanded.has(g.name)}
|
||||||
<div>
|
<div>
|
||||||
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
|
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
|
||||||
<img src={thumbUrl(g.icon)} alt={g.name} class="icon"
|
<Thumbnail src={g.icon} alt={g.name} class="icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||||
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="name">{g.name}</span>
|
<span class="name">{g.name}</span>
|
||||||
<span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span>
|
<span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span>
|
||||||
@@ -95,18 +97,20 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div><!-- .content -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { padding: var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-5); }
|
.content { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
.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; }
|
.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; }
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||||
.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: 180px; 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: 180px; 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); }
|
||||||
.lang-row { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-bottom: var(--sp-4); }
|
.lang-row { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
.lang-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); padding: 3px 8px; 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), background var(--t-base); }
|
.lang-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); padding: 3px 8px; 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), background var(--t-base); }
|
||||||
.lang-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
.lang-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
.lang-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
.lang-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { thumbUrl, plainThumbUrl } from "../../lib/client";
|
||||||
|
import { store } from "../../store/state.svelte";
|
||||||
|
import { getBlobUrl } from "../../lib/imageCache";
|
||||||
|
|
||||||
|
let {
|
||||||
|
src,
|
||||||
|
alt = "",
|
||||||
|
class: cls = "",
|
||||||
|
loading = "lazy",
|
||||||
|
decoding = "async",
|
||||||
|
priority = 0,
|
||||||
|
onerror = undefined,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
class?: string;
|
||||||
|
loading?: string;
|
||||||
|
decoding?: string;
|
||||||
|
priority?: number;
|
||||||
|
onerror?: ((e: Event) => void) | undefined;
|
||||||
|
[key: string]: any;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isAuth = $derived(store.settings.serverAuthMode === "BASIC_AUTH");
|
||||||
|
|
||||||
|
let blobUrl = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
if (!isAuth || !src) { blobUrl = ""; return; }
|
||||||
|
getBlobUrl(plainThumbUrl(src), priority)
|
||||||
|
.then(u => { blobUrl = u; })
|
||||||
|
.catch(() => { blobUrl = ""; });
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = $derived(
|
||||||
|
isAuth
|
||||||
|
? (blobUrl || undefined)
|
||||||
|
: (src ? thumbUrl(src) : undefined)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { store, updateSettings } from "../store/state.svelte";
|
||||||
|
|
||||||
|
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||||
|
|
||||||
|
export const authSession = {
|
||||||
|
clearTokens() {},
|
||||||
|
hasSession(): boolean { return true; },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getServerBase(): string {
|
||||||
|
const url = store.settings.serverUrl;
|
||||||
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||||
|
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAuthenticated(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Response> {
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
signal,
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
...(init.headers as Record<string, string> ?? {}),
|
||||||
|
...(user && pass ? basicHeader(user, pass) : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, { ...init, signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||||
|
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||||
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||||
|
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
updateSettings({ serverAuthPass: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
||||||
|
const base = getServerBase();
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
const s = store.settings;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const user = s.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = s.serverAuthPass?.trim() ?? "";
|
||||||
|
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
||||||
|
|
||||||
|
if (/basic/i.test(wwwAuth)) {
|
||||||
|
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
||||||
|
return "auth_required";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/bearer/i.test(wwwAuth)) {
|
||||||
|
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||||
|
} else if (mode === "NONE") {
|
||||||
|
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
||||||
|
}
|
||||||
|
return "unsupported_mode";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unreachable";
|
||||||
|
} catch { return "unreachable"; }
|
||||||
|
}
|
||||||
@@ -154,6 +154,7 @@ export const CACHE_GROUPS = {
|
|||||||
export const CACHE_KEYS = {
|
export const CACHE_KEYS = {
|
||||||
LIBRARY: "library",
|
LIBRARY: "library",
|
||||||
ALL_MANGA: "all_manga_unfiltered",
|
ALL_MANGA: "all_manga_unfiltered",
|
||||||
|
CATEGORIES: "categories",
|
||||||
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
|
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
|
||||||
SOURCES: "sources",
|
SOURCES: "sources",
|
||||||
POPULAR: "popular",
|
POPULAR: "popular",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Chapter } from "./types";
|
||||||
|
|
||||||
|
export function buildReaderChapterList(
|
||||||
|
chapters: Chapter[],
|
||||||
|
mangaPrefs: { preferredScanlator?: string; scanlatorFilter?: string[] } | undefined,
|
||||||
|
): Chapter[] {
|
||||||
|
const preferred = mangaPrefs?.preferredScanlator ?? "";
|
||||||
|
const filter = mangaPrefs?.scanlatorFilter ?? [];
|
||||||
|
|
||||||
|
let base = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
|
||||||
|
if (preferred) {
|
||||||
|
const pref: Chapter[] = [], rest: Chapter[] = [];
|
||||||
|
for (const c of base) (c.scanlator === preferred ? pref : rest).push(c);
|
||||||
|
base = [...pref, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.length > 0) {
|
||||||
|
const seen = new Map<number, Chapter>();
|
||||||
|
for (const ch of base) {
|
||||||
|
const existing = seen.get(ch.chapterNumber);
|
||||||
|
if (!existing) {
|
||||||
|
seen.set(ch.chapterNumber, ch);
|
||||||
|
} else {
|
||||||
|
const np = filter.indexOf(ch.scanlator ?? "");
|
||||||
|
const op = filter.indexOf(existing.scanlator ?? "");
|
||||||
|
if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base = [...seen.values()].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
@@ -1,31 +1,30 @@
|
|||||||
|
import { store } from "../store/state.svelte";
|
||||||
|
import { fetchAuthenticated } from "./auth";
|
||||||
|
|
||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
function getServerUrl(): string {
|
function getServerUrl(): string {
|
||||||
try {
|
const url = store.settings.serverUrl;
|
||||||
const raw = localStorage.getItem("moku-store");
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
const url = parsed?.state?.settings?.serverUrl;
|
|
||||||
if (typeof url === "string" && url.trim()) return url.replace(/\/$/, "");
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return DEFAULT_URL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||||
|
|
||||||
export function thumbUrl(path: string): string {
|
export function plainThumbUrl(path: string): string {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path.startsWith("http")) return path;
|
if (path.startsWith("http")) return path;
|
||||||
return `${getServerUrl()}${path}`;
|
return `${getServerUrl()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function thumbUrl(path: string): string {
|
||||||
|
return plainThumbUrl(path);
|
||||||
|
}
|
||||||
|
|
||||||
interface GQLResponse<T> {
|
interface GQLResponse<T> {
|
||||||
data: T;
|
data: T;
|
||||||
errors?: { message: string }[];
|
errors?: { message: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */
|
|
||||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||||
@@ -37,42 +36,26 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry wrapper with these guarantees:
|
|
||||||
* 1. AbortErrors always propagate immediately — no retry, no delay.
|
|
||||||
* 2. Retry delays are abort-aware — closing a manga mid-delay doesn't hang.
|
|
||||||
* 3. If the signal is already aborted before we even start, we bail instantly.
|
|
||||||
*/
|
|
||||||
async function fetchWithRetry(
|
async function fetchWithRetry(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
retries = 3,
|
retries = 3,
|
||||||
delayMs = 300,
|
delayMs = 300,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
// Bail immediately if already aborted before we start
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
// Check abort at the top of every iteration
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { ...init, signal });
|
const res = await fetchAuthenticated(url, init, signal);
|
||||||
|
|
||||||
// Check abort again — fetch can return a response even after abort in some runtimes
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Never retry aborted requests
|
if (e?.authRequired) throw e;
|
||||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
// Last retry — give up
|
|
||||||
if (i === retries - 1) throw e;
|
if (i === retries - 1) throw e;
|
||||||
|
|
||||||
// Abort-aware delay between retries
|
|
||||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,17 +63,16 @@ async function fetchWithRetry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function gql<T>(
|
export async function gql<T>(
|
||||||
query: string,
|
query: string,
|
||||||
variables?: Record<string, unknown>,
|
variables?: Record<string, unknown>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchWithRetry(gqlUrl(), {
|
const res = await fetchWithRetry(gqlUrl(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
}, signal);
|
}, signal);
|
||||||
|
|
||||||
// Check abort before reading the body — avoids hanging on res.json() after cancel
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { connect, disconnect, setActivity, clearActivity } from "tauri-plugin-discord-rpc-api";
|
||||||
|
import { listen } from '@tauri-apps/api/event'
|
||||||
|
import type { Manga, Chapter } from './types'
|
||||||
|
|
||||||
|
const APP_ID = '1487894643613106298'
|
||||||
|
const FALLBACK_IMAGE = 'moku_logo'
|
||||||
|
|
||||||
|
let sessionStart: number | null = null
|
||||||
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
|
function isPublicUrl(url: string | null | undefined): boolean {
|
||||||
|
return typeof url === 'string' && url.startsWith('https://')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCoverImage(manga: Manga): string {
|
||||||
|
return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
function trunc(s: string, max = 128): string {
|
||||||
|
return s.length <= max ? s : `${s.slice(0, max - 1)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChapter(chapter: Chapter): string {
|
||||||
|
const n = chapter.chapterNumber
|
||||||
|
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUTTONS = [
|
||||||
|
{ label: 'GitHub', url: 'https://github.com/Youwes09/Moku' },
|
||||||
|
{ label: 'Discord', url: 'https://discord.gg/Jq3pwuNqPp' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function initRpc(): Promise<void> {
|
||||||
|
sessionStart = Date.now()
|
||||||
|
|
||||||
|
unlisten = await listen('discord-rpc://running', ({ payload }) => {
|
||||||
|
if (payload) setIdle().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
await connect(APP_ID).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
|
||||||
|
await setActivity({
|
||||||
|
details: trunc(manga.title),
|
||||||
|
state: `${formatChapter(chapter)} · Reading`,
|
||||||
|
timestamps: { start: sessionStart ?? Date.now() },
|
||||||
|
assets: {
|
||||||
|
largeImage: resolveCoverImage(manga),
|
||||||
|
largeText: trunc(manga.title),
|
||||||
|
smallImage: FALLBACK_IMAGE,
|
||||||
|
smallText: 'Moku',
|
||||||
|
},
|
||||||
|
buttons: BUTTONS,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setIdle(): Promise<void> {
|
||||||
|
await setActivity({
|
||||||
|
details: 'Browsing',
|
||||||
|
timestamps: { start: sessionStart ?? Date.now() },
|
||||||
|
assets: {
|
||||||
|
largeImage: FALLBACK_IMAGE,
|
||||||
|
largeText: 'Moku',
|
||||||
|
},
|
||||||
|
buttons: BUTTONS,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearReading(): Promise<void> {
|
||||||
|
await clearActivity().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroyRpc(): Promise<void> {
|
||||||
|
unlisten?.()
|
||||||
|
unlisten = null
|
||||||
|
sessionStart = null
|
||||||
|
await disconnect().catch(() => {})
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
|
import { store } from "../store/state.svelte";
|
||||||
|
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
const inflight = new Map<string, Promise<string>>();
|
||||||
|
|
||||||
|
const MAX_CONCURRENT = 14;
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
interface QueueEntry {
|
||||||
|
url: string;
|
||||||
|
priority: number;
|
||||||
|
resolve: (v: string) => void;
|
||||||
|
reject: (e: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue: QueueEntry[] = [];
|
||||||
|
|
||||||
|
function getAuthHeaders(): Record<string, string> {
|
||||||
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetch(url: string): Promise<string> {
|
||||||
|
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
const blobUrl = URL.createObjectURL(await res.blob());
|
||||||
|
cache.set(url, blobUrl);
|
||||||
|
return blobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain() {
|
||||||
|
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||||
|
queue.sort((a, b) => b.priority - a.priority);
|
||||||
|
const entry = queue.shift()!;
|
||||||
|
active++;
|
||||||
|
doFetch(entry.url)
|
||||||
|
.then(entry.resolve, entry.reject)
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(entry.url);
|
||||||
|
active--;
|
||||||
|
drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(url: string, priority: number): Promise<string> {
|
||||||
|
const promise = new Promise<string>((resolve, reject) => {
|
||||||
|
queue.push({ url, priority, resolve, reject });
|
||||||
|
});
|
||||||
|
inflight.set(url, promise);
|
||||||
|
drain();
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlobUrl(url: string, priority = 0): Promise<string> {
|
||||||
|
if (!url) return Promise.resolve("");
|
||||||
|
|
||||||
|
const cached = cache.get(url);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
|
||||||
|
const existing = inflight.get(url);
|
||||||
|
if (existing) {
|
||||||
|
const entry = queue.find(e => e.url === url);
|
||||||
|
if (entry && priority > entry.priority) entry.priority = priority;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return enqueue(url, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
|
||||||
|
urls.forEach((url, i) => {
|
||||||
|
if (!url || cache.has(url) || inflight.has(url)) return;
|
||||||
|
enqueue(url, basePriority - i);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeBlobUrl(url: string): void {
|
||||||
|
const blob = cache.get(url);
|
||||||
|
if (blob) {
|
||||||
|
URL.revokeObjectURL(blob);
|
||||||
|
cache.delete(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBlobCache(): void {
|
||||||
|
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export interface Keybinds {
|
|||||||
togglePageStyle: string;
|
togglePageStyle: string;
|
||||||
toggleFullscreen: string;
|
toggleFullscreen: string;
|
||||||
openSettings: string;
|
openSettings: string;
|
||||||
|
toggleBookmark: string;
|
||||||
|
toggleMarker: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||||
@@ -26,6 +28,8 @@ export const DEFAULT_KEYBINDS: Keybinds = {
|
|||||||
togglePageStyle: "q",
|
togglePageStyle: "q",
|
||||||
toggleFullscreen: "f",
|
toggleFullscreen: "f",
|
||||||
openSettings: "o",
|
openSettings: "o",
|
||||||
|
toggleBookmark: "m",
|
||||||
|
toggleMarker: "n",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||||
@@ -40,6 +44,8 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
|||||||
togglePageStyle: "Toggle page style",
|
togglePageStyle: "Toggle page style",
|
||||||
toggleFullscreen: "Toggle fullscreen",
|
toggleFullscreen: "Toggle fullscreen",
|
||||||
openSettings: "Open settings",
|
openSettings: "Open settings",
|
||||||
|
toggleBookmark: "Toggle bookmark",
|
||||||
|
toggleMarker: "Toggle marker",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function eventToKeybind(e: KeyboardEvent): string {
|
export function eventToKeybind(e: KeyboardEvent): string {
|
||||||
|
|||||||
@@ -187,6 +187,112 @@ export const GET_DOWNLOADS_PATH = `
|
|||||||
query GetDownloadsPath {
|
query GetDownloadsPath {
|
||||||
settings {
|
settings {
|
||||||
downloadsPath
|
downloadsPath
|
||||||
|
localSourcePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_DOWNLOADS_PATH = `
|
||||||
|
mutation SetDownloadsPath($path: String!) {
|
||||||
|
setSettings(input: { settings: { downloadsPath: $path } }) {
|
||||||
|
settings { downloadsPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_LOCAL_SOURCE_PATH = `
|
||||||
|
mutation SetLocalSourcePath($path: String!) {
|
||||||
|
setSettings(input: { settings: { localSourcePath: $path } }) {
|
||||||
|
settings { localSourcePath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Categories ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const GET_CATEGORIES = `
|
||||||
|
query GetCategories {
|
||||||
|
categories {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
order
|
||||||
|
default
|
||||||
|
includeInUpdate
|
||||||
|
includeInDownload
|
||||||
|
mangas {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
thumbnailUrl
|
||||||
|
inLibrary
|
||||||
|
downloadCount
|
||||||
|
unreadCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CREATE_CATEGORY = `
|
||||||
|
mutation CreateCategory($name: String!) {
|
||||||
|
createCategory(input: { name: $name }) {
|
||||||
|
category {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
order
|
||||||
|
default
|
||||||
|
includeInUpdate
|
||||||
|
includeInDownload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORY = `
|
||||||
|
mutation UpdateCategory($id: Int!, $name: String) {
|
||||||
|
updateCategory(input: { id: $id, patch: { name: $name } }) {
|
||||||
|
category {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_CATEGORY = `
|
||||||
|
mutation DeleteCategory($id: Int!) {
|
||||||
|
deleteCategory(input: { categoryId: $id }) {
|
||||||
|
category {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORY_ORDER = `
|
||||||
|
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
|
||||||
|
updateCategoryOrder(input: { id: $id, position: $position }) {
|
||||||
|
categories {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
order
|
||||||
|
default
|
||||||
|
includeInUpdate
|
||||||
|
includeInDownload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGA_CATEGORIES = `
|
||||||
|
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||||
|
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||||
|
manga {
|
||||||
|
id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -335,8 +441,8 @@ export const GET_SOURCES = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const FETCH_SOURCE_MANGA = `
|
export const FETCH_SOURCE_MANGA = `
|
||||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
|
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
|
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||||
mangas {
|
mangas {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
@@ -436,6 +542,7 @@ export const INSTALL_EXTERNAL_EXTENSION = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const GET_SETTINGS = `
|
export const GET_SETTINGS = `
|
||||||
@@ -455,3 +562,346 @@ export const SET_EXTENSION_REPOS = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const GET_SERVER_SECURITY = `
|
||||||
|
query GetServerSecurity {
|
||||||
|
settings {
|
||||||
|
authMode
|
||||||
|
authUsername
|
||||||
|
socksProxyEnabled
|
||||||
|
socksProxyHost
|
||||||
|
socksProxyPort
|
||||||
|
socksProxyVersion
|
||||||
|
socksProxyUsername
|
||||||
|
flareSolverrEnabled
|
||||||
|
flareSolverrUrl
|
||||||
|
flareSolverrTimeout
|
||||||
|
flareSolverrSessionName
|
||||||
|
flareSolverrSessionTtl
|
||||||
|
flareSolverrAsResponseFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_SERVER_AUTH = `
|
||||||
|
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
|
||||||
|
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
|
||||||
|
settings {
|
||||||
|
authMode
|
||||||
|
authUsername
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_SOCKS_PROXY = `
|
||||||
|
mutation SetSocksProxy(
|
||||||
|
$socksProxyEnabled: Boolean!
|
||||||
|
$socksProxyHost: String!
|
||||||
|
$socksProxyPort: String!
|
||||||
|
$socksProxyVersion: Int!
|
||||||
|
$socksProxyUsername: String!
|
||||||
|
$socksProxyPassword: String!
|
||||||
|
) {
|
||||||
|
setSettings(input: { settings: {
|
||||||
|
socksProxyEnabled: $socksProxyEnabled
|
||||||
|
socksProxyHost: $socksProxyHost
|
||||||
|
socksProxyPort: $socksProxyPort
|
||||||
|
socksProxyVersion: $socksProxyVersion
|
||||||
|
socksProxyUsername: $socksProxyUsername
|
||||||
|
socksProxyPassword: $socksProxyPassword
|
||||||
|
}}) {
|
||||||
|
settings {
|
||||||
|
socksProxyEnabled
|
||||||
|
socksProxyHost
|
||||||
|
socksProxyPort
|
||||||
|
socksProxyVersion
|
||||||
|
socksProxyUsername
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_FLARESOLVERR = `
|
||||||
|
mutation SetFlareSolverr(
|
||||||
|
$flareSolverrEnabled: Boolean!
|
||||||
|
$flareSolverrUrl: String!
|
||||||
|
$flareSolverrTimeout: Int!
|
||||||
|
$flareSolverrSessionName: String!
|
||||||
|
$flareSolverrSessionTtl: Int!
|
||||||
|
$flareSolverrAsResponseFallback: Boolean!
|
||||||
|
) {
|
||||||
|
setSettings(input: { settings: {
|
||||||
|
flareSolverrEnabled: $flareSolverrEnabled
|
||||||
|
flareSolverrUrl: $flareSolverrUrl
|
||||||
|
flareSolverrTimeout: $flareSolverrTimeout
|
||||||
|
flareSolverrSessionName: $flareSolverrSessionName
|
||||||
|
flareSolverrSessionTtl: $flareSolverrSessionTtl
|
||||||
|
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
|
||||||
|
}}) {
|
||||||
|
settings {
|
||||||
|
flareSolverrEnabled
|
||||||
|
flareSolverrUrl
|
||||||
|
flareSolverrTimeout
|
||||||
|
flareSolverrSessionName
|
||||||
|
flareSolverrSessionTtl
|
||||||
|
flareSolverrAsResponseFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Trackers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const GET_TRACKERS = `
|
||||||
|
query GetTrackers {
|
||||||
|
trackers {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
icon
|
||||||
|
isLoggedIn
|
||||||
|
authUrl
|
||||||
|
supportsPrivateTracking
|
||||||
|
scores
|
||||||
|
statuses {
|
||||||
|
value
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MANGA_TRACK_RECORDS = `
|
||||||
|
query GetMangaTrackRecords($mangaId: Int!) {
|
||||||
|
manga(id: $mangaId) {
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
trackerId
|
||||||
|
remoteId
|
||||||
|
title
|
||||||
|
status
|
||||||
|
score
|
||||||
|
displayScore
|
||||||
|
lastChapterRead
|
||||||
|
totalChapters
|
||||||
|
remoteUrl
|
||||||
|
startDate
|
||||||
|
finishDate
|
||||||
|
private
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SEARCH_TRACKER = `
|
||||||
|
query SearchTracker($trackerId: Int!, $query: String!) {
|
||||||
|
searchTracker(input: { trackerId: $trackerId, query: $query }) {
|
||||||
|
trackSearches {
|
||||||
|
id
|
||||||
|
trackerId
|
||||||
|
remoteId
|
||||||
|
title
|
||||||
|
coverUrl
|
||||||
|
summary
|
||||||
|
publishingStatus
|
||||||
|
publishingType
|
||||||
|
startDate
|
||||||
|
totalChapters
|
||||||
|
trackingUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BIND_TRACK = `
|
||||||
|
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||||
|
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||||
|
trackRecord {
|
||||||
|
id
|
||||||
|
trackerId
|
||||||
|
remoteId
|
||||||
|
title
|
||||||
|
status
|
||||||
|
score
|
||||||
|
displayScore
|
||||||
|
lastChapterRead
|
||||||
|
totalChapters
|
||||||
|
remoteUrl
|
||||||
|
startDate
|
||||||
|
finishDate
|
||||||
|
private
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_TRACK = `
|
||||||
|
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
||||||
|
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
|
||||||
|
trackRecord {
|
||||||
|
id
|
||||||
|
trackerId
|
||||||
|
status
|
||||||
|
score
|
||||||
|
displayScore
|
||||||
|
lastChapterRead
|
||||||
|
totalChapters
|
||||||
|
startDate
|
||||||
|
finishDate
|
||||||
|
private
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UNBIND_TRACK = `
|
||||||
|
mutation UnbindTrack($recordId: Int!) {
|
||||||
|
unbindTrack(input: { recordId: $recordId }) {
|
||||||
|
trackRecord {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_TRACK = `
|
||||||
|
mutation FetchTrack($recordId: Int!) {
|
||||||
|
fetchTrack(input: { recordId: $recordId }) {
|
||||||
|
trackRecord {
|
||||||
|
id
|
||||||
|
trackerId
|
||||||
|
status
|
||||||
|
score
|
||||||
|
displayScore
|
||||||
|
lastChapterRead
|
||||||
|
totalChapters
|
||||||
|
startDate
|
||||||
|
finishDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ALL_TRACKER_RECORDS = `
|
||||||
|
query GetAllTrackerRecords {
|
||||||
|
trackers {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
icon
|
||||||
|
isLoggedIn
|
||||||
|
scores
|
||||||
|
statuses { value name }
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
trackerId
|
||||||
|
title
|
||||||
|
status
|
||||||
|
displayScore
|
||||||
|
lastChapterRead
|
||||||
|
totalChapters
|
||||||
|
remoteUrl
|
||||||
|
private
|
||||||
|
manga {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
thumbnailUrl
|
||||||
|
inLibrary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_TRACKER_RECORDS = `
|
||||||
|
query GetTrackerRecords($trackerId: Int!) {
|
||||||
|
trackers(condition: { id: $trackerId }) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
statuses { value name }
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
status
|
||||||
|
displayScore
|
||||||
|
lastChapterRead
|
||||||
|
totalChapters
|
||||||
|
remoteUrl
|
||||||
|
manga {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
thumbnailUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_TRACKER_OAUTH = `
|
||||||
|
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||||
|
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||||
|
isLoggedIn
|
||||||
|
tracker {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isLoggedIn
|
||||||
|
authUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_TRACKER_CREDENTIALS = `
|
||||||
|
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||||
|
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
||||||
|
isLoggedIn
|
||||||
|
tracker {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isLoggedIn
|
||||||
|
authUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGOUT_TRACKER = `
|
||||||
|
mutation LogoutTracker($trackerId: Int!) {
|
||||||
|
logoutTracker(input: { trackerId: $trackerId }) {
|
||||||
|
tracker {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isLoggedIn
|
||||||
|
authUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_USER = `
|
||||||
|
mutation Login($username: String!, $password: String!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
accessToken
|
||||||
|
refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REFRESH_TOKEN = `
|
||||||
|
mutation RefreshToken {
|
||||||
|
refreshToken {
|
||||||
|
accessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
default: boolean;
|
||||||
|
includeInUpdate: string;
|
||||||
|
includeInDownload: string;
|
||||||
|
mangas?: {
|
||||||
|
nodes: Manga[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface Manga {
|
export interface Manga {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -5,6 +17,7 @@ export interface Manga {
|
|||||||
inLibrary: boolean;
|
inLibrary: boolean;
|
||||||
downloadCount?: number;
|
downloadCount?: number;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
|
chapterCount?: number;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
@@ -87,3 +100,49 @@ export interface DownloadStatus {
|
|||||||
export interface Connection<T> {
|
export interface Connection<T> {
|
||||||
nodes: T[];
|
nodes: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrackerStatus {
|
||||||
|
value: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tracker {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
authUrl: string | null;
|
||||||
|
supportsPrivateTracking: boolean;
|
||||||
|
scores: string[];
|
||||||
|
statuses: TrackerStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackRecord {
|
||||||
|
id: number;
|
||||||
|
trackerId: number;
|
||||||
|
remoteId: string;
|
||||||
|
title: string;
|
||||||
|
status: number;
|
||||||
|
score: number;
|
||||||
|
displayScore: string;
|
||||||
|
lastChapterRead: number;
|
||||||
|
totalChapters: number;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
startDate: string | null;
|
||||||
|
finishDate: string | null;
|
||||||
|
private: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackSearch {
|
||||||
|
id: number;
|
||||||
|
trackerId: number;
|
||||||
|
remoteId: string;
|
||||||
|
title: string;
|
||||||
|
coverUrl: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
publishingStatus: string | null;
|
||||||
|
publishingType: string | null;
|
||||||
|
startDate: string | null;
|
||||||
|
totalChapters: number;
|
||||||
|
trackingUrl: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,105 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return clsx(inputs);
|
return clsx(inputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NSFW genre filtering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default substrings used when no user-configured list is available.
|
||||||
|
* The Settings > Content tab lets users add/remove entries from this list,
|
||||||
|
* which is stored as settings.nsfwFilteredTags.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_NSFW_TAGS = [
|
||||||
|
"adult",
|
||||||
|
"mature",
|
||||||
|
"hentai",
|
||||||
|
"ecchi",
|
||||||
|
"erotic", // catches "erotica", "erotic content", "erotic manga"
|
||||||
|
"pornograph", // catches "pornographic", "pornography"
|
||||||
|
"18+",
|
||||||
|
"smut",
|
||||||
|
"lemon",
|
||||||
|
"explicit",
|
||||||
|
"sexual violence",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the manga carries at least one genre tag matching any of
|
||||||
|
* the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags
|
||||||
|
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
|
||||||
|
*/
|
||||||
|
export function isNsfwManga(
|
||||||
|
manga: { genre?: string[] | null },
|
||||||
|
tags: string[] = DEFAULT_NSFW_TAGS,
|
||||||
|
): boolean {
|
||||||
|
return (manga.genre ?? []).some((g) => {
|
||||||
|
const normalized = g.toLowerCase().trim();
|
||||||
|
return tags.some((sub) => normalized.includes(sub));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single authoritative NSFW gate used by all views.
|
||||||
|
*
|
||||||
|
* Returns true when the manga should be HIDDEN. Checks in order:
|
||||||
|
* 1. showNsfw disabled globally → skip everything, hide by source flag or genre match.
|
||||||
|
* 2. Source is in blockedSourceIds → always hide regardless of showNsfw.
|
||||||
|
* 3. Source is in allowedSourceIds → always show (bypasses isNsfw flag only, genre tags still apply).
|
||||||
|
* 4. Source isNsfw flag → hide unless source is allowed.
|
||||||
|
* 5. Genre tag match → hide.
|
||||||
|
*
|
||||||
|
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
|
||||||
|
*/
|
||||||
|
export function shouldHideNsfw(
|
||||||
|
manga: {
|
||||||
|
genre?: string[] | null;
|
||||||
|
source?: { id?: string; isNsfw?: boolean } | null;
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
showNsfw: boolean;
|
||||||
|
nsfwFilteredTags: string[];
|
||||||
|
nsfwAllowedSourceIds: string[];
|
||||||
|
nsfwBlockedSourceIds: string[];
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
const srcId = manga.source?.id;
|
||||||
|
|
||||||
|
// Explicit block always wins, even when showNsfw is on
|
||||||
|
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
|
||||||
|
|
||||||
|
// If NSFW is globally allowed, only explicit blocks apply
|
||||||
|
if (settings.showNsfw) return false;
|
||||||
|
|
||||||
|
// Source is explicitly allowed — skip the isNsfw flag check, but still filter genres
|
||||||
|
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
|
||||||
|
|
||||||
|
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||||
|
|
||||||
|
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate for Source objects — parallel to shouldHideNsfw for manga.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. Blocked list → always hidden, even when showNsfw is on.
|
||||||
|
* 2. Allowed list → always shown, even if isNsfw is true.
|
||||||
|
* 3. Fallback → hide when showNsfw is off and source.isNsfw is true.
|
||||||
|
*
|
||||||
|
* Usage: sources.filter(s => !shouldHideSource(s, settings))
|
||||||
|
*/
|
||||||
|
export function shouldHideSource(
|
||||||
|
source: { id: string; isNsfw: boolean },
|
||||||
|
settings: {
|
||||||
|
showNsfw: boolean;
|
||||||
|
nsfwAllowedSourceIds: string[];
|
||||||
|
nsfwBlockedSourceIds: string[];
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
|
||||||
|
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
|
||||||
|
return !settings.showNsfw && source.isNsfw;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
|
|||||||
@@ -1,15 +1,104 @@
|
|||||||
import type { Manga, Chapter, Source } from "../lib/types";
|
import type { Manga, Chapter, Category, Source } from "../lib/types";
|
||||||
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
||||||
|
|
||||||
export type PageStyle = "single" | "double" | "longstrip";
|
export type PageStyle = "single" | "double" | "longstrip";
|
||||||
export type FitMode = "width" | "height" | "screen" | "original";
|
export type FitMode = "width" | "height" | "screen" | "original";
|
||||||
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
||||||
export type NavPage = "home" | "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
export type NavPage = "home" | "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search" | "tracking";
|
||||||
export type ReadingDirection = "ltr" | "rtl";
|
export type ReadingDirection = "ltr" | "rtl";
|
||||||
export type ChapterSortDir = "desc" | "asc";
|
export type ChapterSortDir = "desc" | "asc";
|
||||||
export type Theme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
|
||||||
|
|
||||||
export const COMPLETED_FOLDER_ID = "completed";
|
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 {
|
export interface HistoryEntry {
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
@@ -17,10 +106,43 @@ export interface HistoryEntry {
|
|||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
chapterId: number;
|
chapterId: number;
|
||||||
chapterName: string;
|
chapterName: string;
|
||||||
pageNumber: number;
|
|
||||||
readAt: number;
|
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 {
|
export interface ReadingStats {
|
||||||
totalChaptersRead: number;
|
totalChaptersRead: number;
|
||||||
totalMangaRead: number;
|
totalMangaRead: number;
|
||||||
@@ -59,19 +181,35 @@ export interface ActiveDownload {
|
|||||||
progress: number;
|
progress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Folder {
|
export interface MangaPrefs {
|
||||||
id: string;
|
autoDownload: boolean;
|
||||||
name: string;
|
downloadAhead: number;
|
||||||
mangaIds: number[];
|
deleteOnRead: boolean;
|
||||||
showTab: boolean;
|
deleteDelayHours: number;
|
||||||
system?: boolean;
|
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 {
|
export interface Settings {
|
||||||
pageStyle: PageStyle;
|
pageStyle: PageStyle;
|
||||||
readingDirection: ReadingDirection;
|
readingDirection: ReadingDirection;
|
||||||
fitMode: FitMode;
|
fitMode: FitMode;
|
||||||
maxPageWidth: number;
|
readerZoom: number;
|
||||||
pageGap: boolean;
|
pageGap: boolean;
|
||||||
optimizeContrast: boolean;
|
optimizeContrast: boolean;
|
||||||
offsetDoubleSpreads: boolean;
|
offsetDoubleSpreads: boolean;
|
||||||
@@ -81,9 +219,11 @@ export interface Settings {
|
|||||||
libraryCropCovers: boolean;
|
libraryCropCovers: boolean;
|
||||||
libraryPageSize: number;
|
libraryPageSize: number;
|
||||||
showNsfw: boolean;
|
showNsfw: boolean;
|
||||||
|
discordRpc: boolean;
|
||||||
chapterSortDir: ChapterSortDir;
|
chapterSortDir: ChapterSortDir;
|
||||||
|
chapterSortMode: ChapterSortMode;
|
||||||
chapterPageSize: number;
|
chapterPageSize: number;
|
||||||
uiScale: number;
|
uiZoom: number;
|
||||||
compactSidebar: boolean;
|
compactSidebar: boolean;
|
||||||
gpuAcceleration: boolean;
|
gpuAcceleration: boolean;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
@@ -94,29 +234,53 @@ export interface Settings {
|
|||||||
idleTimeoutMin?: number;
|
idleTimeoutMin?: number;
|
||||||
splashCards?: boolean;
|
splashCards?: boolean;
|
||||||
storageLimitGb: number | null;
|
storageLimitGb: number | null;
|
||||||
folders: Folder[];
|
|
||||||
markReadOnNext: boolean;
|
markReadOnNext: boolean;
|
||||||
readerDebounceMs: number;
|
readerDebounceMs: number;
|
||||||
|
autoBookmark: boolean;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
libraryBranches: boolean;
|
libraryBranches: boolean;
|
||||||
renderLimit: number;
|
renderLimit: number;
|
||||||
heroSlots: (number | null)[];
|
heroSlots: (number | null)[];
|
||||||
mangaLinks: Record<number, number[]>;
|
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;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPLETED_FOLDER_DEFAULT: Folder = {
|
|
||||||
id: COMPLETED_FOLDER_ID,
|
|
||||||
name: "Completed",
|
|
||||||
mangaIds: [],
|
|
||||||
showTab: true,
|
|
||||||
system: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
pageStyle: "longstrip",
|
pageStyle: "longstrip",
|
||||||
readingDirection: "ltr",
|
readingDirection: "ltr",
|
||||||
fitMode: "width",
|
fitMode: "width",
|
||||||
maxPageWidth: 900,
|
readerZoom: 1.0,
|
||||||
pageGap: true,
|
pageGap: true,
|
||||||
optimizeContrast: false,
|
optimizeContrast: false,
|
||||||
offsetDoubleSpreads: false,
|
offsetDoubleSpreads: false,
|
||||||
@@ -126,9 +290,11 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
libraryCropCovers: true,
|
libraryCropCovers: true,
|
||||||
libraryPageSize: 48,
|
libraryPageSize: 48,
|
||||||
showNsfw: false,
|
showNsfw: false,
|
||||||
|
discordRpc: false,
|
||||||
chapterSortDir: "desc",
|
chapterSortDir: "desc",
|
||||||
|
chapterSortMode: "source",
|
||||||
chapterPageSize: 25,
|
chapterPageSize: 25,
|
||||||
uiScale: 100,
|
uiZoom: 1.0,
|
||||||
compactSidebar: false,
|
compactSidebar: false,
|
||||||
gpuAcceleration: true,
|
gpuAcceleration: true,
|
||||||
serverUrl: "http://localhost:4567",
|
serverUrl: "http://localhost:4567",
|
||||||
@@ -139,24 +305,52 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
idleTimeoutMin: 5,
|
idleTimeoutMin: 5,
|
||||||
splashCards: true,
|
splashCards: true,
|
||||||
storageLimitGb: null,
|
storageLimitGb: null,
|
||||||
folders: [COMPLETED_FOLDER_DEFAULT],
|
|
||||||
markReadOnNext: true,
|
markReadOnNext: true,
|
||||||
readerDebounceMs: 120,
|
readerDebounceMs: 120,
|
||||||
|
autoBookmark: true,
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
libraryBranches: true,
|
libraryBranches: true,
|
||||||
renderLimit: 48,
|
renderLimit: 48,
|
||||||
heroSlots: [null, null, null, null],
|
heroSlots: [null, null, null, null],
|
||||||
mangaLinks: {},
|
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,
|
||||||
|
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
||||||
|
nsfwAllowedSourceIds: [],
|
||||||
|
nsfwBlockedSourceIds: [],
|
||||||
|
libraryTabSort: {},
|
||||||
|
libraryTabStatus: {},
|
||||||
|
libraryTabFilters: {},
|
||||||
|
extraScanDirs: [],
|
||||||
|
serverDownloadsPath: "",
|
||||||
|
serverLocalSourcePath: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
const STORE_VERSION = 3;
|
||||||
|
|
||||||
const STORE_VERSION = 2;
|
|
||||||
|
|
||||||
// Fields reset to their DEFAULT_SETTINGS value on each version bump.
|
|
||||||
// Add a key here whenever its default changes meaning between releases.
|
|
||||||
const RESET_ON_UPGRADE: (keyof Settings)[] = [
|
const RESET_ON_UPGRADE: (keyof Settings)[] = [
|
||||||
"serverBinary",
|
"serverBinary",
|
||||||
|
"readerZoom",
|
||||||
|
"uiZoom",
|
||||||
];
|
];
|
||||||
|
|
||||||
function loadPersisted(): any {
|
function loadPersisted(): any {
|
||||||
@@ -197,72 +391,72 @@ const saved = (() => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
function mergeSettings(saved: any): Settings {
|
function mergeSettings(saved: any): Settings {
|
||||||
const userFolders: Folder[] = saved?.settings?.folders ?? [];
|
|
||||||
const existingCompleted = userFolders.find(f => f.id === COMPLETED_FOLDER_ID);
|
|
||||||
const completedFolder: Folder = existingCompleted
|
|
||||||
? { ...COMPLETED_FOLDER_DEFAULT, mangaIds: existingCompleted.mangaIds }
|
|
||||||
: COMPLETED_FOLDER_DEFAULT;
|
|
||||||
const otherFolders = userFolders.filter(f => f.id !== COMPLETED_FOLDER_ID);
|
|
||||||
return {
|
return {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...saved?.settings,
|
...saved?.settings,
|
||||||
folders: [completedFolder, ...otherFolders],
|
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||||
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
||||||
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
||||||
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
|
||||||
|
customThemes: saved?.settings?.customThemes ?? [],
|
||||||
|
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
||||||
|
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? DEFAULT_SETTINGS.nsfwFilteredTags,
|
||||||
|
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
||||||
|
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
||||||
|
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||||
|
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
||||||
|
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
|
||||||
|
extraScanDirs: saved?.settings?.extraScanDirs ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeStats(saved: any): ReadingStats {
|
|
||||||
return { ...DEFAULT_READING_STATS, ...saved?.readingStats };
|
|
||||||
}
|
|
||||||
|
|
||||||
function todayStr(): string {
|
|
||||||
const d = new Date();
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const genId = () => Math.random().toString(36).slice(2, 10);
|
|
||||||
|
|
||||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
navPage: NavPage = $state(saved?.navPage ?? "home");
|
settings: Settings = $state(mergeSettings(saved));
|
||||||
libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
|
activeManga: Manga | null = $state(null);
|
||||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
previewManga: Manga | null = $state(null);
|
||||||
readingStats: ReadingStats = $state(mergeStats(saved));
|
activeChapter: Chapter | null = $state(null);
|
||||||
settings: Settings = $state(mergeSettings(saved));
|
activeChapterList: Chapter[] = $state([]);
|
||||||
|
pageUrls: string[] = $state([]);
|
||||||
genreFilter: string = $state("");
|
pageNumber: number = $state(1);
|
||||||
searchPrefill: string = $state("");
|
navPage: NavPage = $state("home");
|
||||||
activeManga: Manga | null = $state(null);
|
libraryFilter: LibraryFilter = $state("all");
|
||||||
previewManga: Manga | null = $state(null);
|
genreFilter: string = $state("");
|
||||||
activeSource: Source | null = $state(null);
|
searchPrefill: string = $state("");
|
||||||
pageUrls: string[] = $state([]);
|
toasts: Toast[] = $state([]);
|
||||||
pageNumber: number = $state(1);
|
categories: Category[] = $state([]);
|
||||||
libraryTagFilter: string[] = $state([]);
|
activeDownloads: ActiveDownload[] = $state([]);
|
||||||
settingsOpen: boolean = $state(false);
|
activeSource: Source | null = $state(null);
|
||||||
activeDownloads: ActiveDownload[] = $state([]);
|
libraryTagFilter: string[] = $state([]);
|
||||||
toasts: Toast[] = $state([]);
|
settingsOpen: boolean = $state(false);
|
||||||
activeChapter: Chapter | null = $state(null);
|
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||||
activeChapterList: Chapter[] = $state([]);
|
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
|
||||||
|
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||||
|
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||||
|
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
||||||
|
discoverCache: Map<string, any> = $state(new Map());
|
||||||
|
discoverLibraryIds: Set<number> = $state(new Set());
|
||||||
|
discoverSrcOffset: number = $state(0);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
$effect(() => { persist({ storeVersion: STORE_VERSION }); });
|
$effect(() => {
|
||||||
$effect(() => { persist({ navPage: this.navPage }); });
|
persist({
|
||||||
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
settings: this.settings,
|
||||||
$effect(() => { persist({ history: this.history }); });
|
history: this.history,
|
||||||
$effect(() => { persist({ readingStats: this.readingStats }); });
|
bookmarks: this.bookmarks,
|
||||||
$effect(() => { persist({ settings: this.settings }); });
|
markers: this.markers,
|
||||||
|
readLog: this.readLog,
|
||||||
|
readingStats: this.readingStats,
|
||||||
|
storeVersion: STORE_VERSION,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openReader(chapter: Chapter, chapterList: Chapter[]) {
|
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||||
this.activeChapter = chapter;
|
this.activeChapter = chapter;
|
||||||
this.activeChapterList = chapterList;
|
this.activeChapterList = chapterList;
|
||||||
this.pageUrls = [];
|
if (manga !== undefined) this.activeManga = manga;
|
||||||
this.pageNumber = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeReader() {
|
closeReader() {
|
||||||
@@ -272,71 +466,112 @@ class Store {
|
|||||||
this.pageNumber = 1;
|
this.pageNumber = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
addHistory(entry: HistoryEntry) {
|
addHistory(entry: HistoryEntry, completed = false, minutes?: number) {
|
||||||
const isNewChapter = !this.history.some(x => x.chapterId === entry.chapterId);
|
const filtered = this.history.filter(h => h.chapterId !== entry.chapterId);
|
||||||
|
this.history = [entry, ...filtered].slice(0, 500);
|
||||||
|
|
||||||
if (this.history[0]?.chapterId === entry.chapterId) {
|
if (completed) {
|
||||||
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
const existing = this.readLog.find(e => e.chapterId === entry.chapterId);
|
||||||
} else {
|
if (!existing) {
|
||||||
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
|
const mins = minutes ?? AVG_MIN_PER_CHAPTER;
|
||||||
|
this.readLog = [...this.readLog, { mangaId: entry.mangaId, chapterId: entry.chapterId, readAt: entry.readAt, minutes: mins }];
|
||||||
|
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
||||||
|
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
||||||
|
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr = now.toISOString().slice(0, 10);
|
||||||
|
const lastDate = this.readingStats.lastStreakDate;
|
||||||
|
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yesterdayStr = yesterday.toISOString().slice(0, 10);
|
||||||
|
let streak = this.readingStats.currentStreakDays;
|
||||||
|
if (lastDate === todayStr) {
|
||||||
|
} else if (lastDate === yesterdayStr) {
|
||||||
|
streak++;
|
||||||
|
} else {
|
||||||
|
streak = 1;
|
||||||
|
}
|
||||||
|
const longest = Math.max(this.readingStats.longestStreakDays, streak);
|
||||||
|
this.readingStats = {
|
||||||
|
totalChaptersRead: uniqueChapters.size,
|
||||||
|
totalMangaRead: uniqueManga.size,
|
||||||
|
totalMinutesRead: totalMinutes,
|
||||||
|
firstReadAt: this.readingStats.firstReadAt || entry.readAt,
|
||||||
|
lastReadAt: entry.readAt,
|
||||||
|
currentStreakDays: streak,
|
||||||
|
longestStreakDays: longest,
|
||||||
|
lastStreakDate: todayStr,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueChapters = new Set(this.history.map(e => e.chapterId));
|
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
||||||
const uniqueManga = new Set(this.history.map(e => e.mangaId));
|
const filtered = this.bookmarks.filter(b => b.chapterId !== entry.chapterId);
|
||||||
|
this.bookmarks = [{ ...entry, savedAt: Date.now(), label }, ...filtered].slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
const today = todayStr();
|
removeBookmark(chapterId: number) {
|
||||||
let { currentStreakDays, longestStreakDays, lastStreakDate } = this.readingStats;
|
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
|
||||||
if (lastStreakDate !== today) {
|
}
|
||||||
const yesterday = new Date();
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
|
|
||||||
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
|
|
||||||
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
|
|
||||||
lastStreakDate = today;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
clearBookmarks() { this.bookmarks = []; }
|
||||||
|
|
||||||
|
getBookmark(chapterId: number): BookmarkEntry | undefined {
|
||||||
|
return this.bookmarks.find(b => b.chapterId === chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
|
||||||
|
const id = Math.random().toString(36).slice(2);
|
||||||
|
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
|
||||||
|
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMarker(id: string) {
|
||||||
|
this.markers = this.markers.filter(m => m.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkersForPage(chapterId: number, page: number): MarkerEntry[] {
|
||||||
|
return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkersForChapter(chapterId: number): MarkerEntry[] {
|
||||||
|
return this.markers.filter(m => m.chapterId === chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkersForManga(mangaId: number): MarkerEntry[] {
|
||||||
|
return this.markers.filter(m => m.mangaId === mangaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMarkersForManga(mangaId: number) {
|
||||||
|
this.markers = this.markers.filter(m => m.mangaId !== mangaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHistory() { this.history = []; this.readLog = []; }
|
||||||
|
|
||||||
|
clearHistoryForManga(mangaId: number) {
|
||||||
|
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
||||||
|
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
||||||
|
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
||||||
|
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
||||||
|
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
|
||||||
this.readingStats = {
|
this.readingStats = {
|
||||||
totalChaptersRead: Math.max(this.readingStats.totalChaptersRead, uniqueChapters.size),
|
...this.readingStats,
|
||||||
totalMangaRead: Math.max(this.readingStats.totalMangaRead, uniqueManga.size),
|
totalChaptersRead: uniqueChapters.size,
|
||||||
totalMinutesRead: this.readingStats.totalMinutesRead + (isNewChapter ? AVG_MIN_PER_CHAPTER : 0),
|
totalMangaRead: uniqueManga.size,
|
||||||
firstReadAt: this.readingStats.firstReadAt === 0 ? entry.readAt : this.readingStats.firstReadAt,
|
totalMinutesRead: totalMinutes,
|
||||||
lastReadAt: entry.readAt,
|
|
||||||
currentStreakDays,
|
|
||||||
longestStreakDays,
|
|
||||||
lastStreakDate,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
clearHistory() { this.history = []; }
|
|
||||||
clearHistoryForManga(mangaId: number) { this.history = this.history.filter(x => x.mangaId !== mangaId); }
|
|
||||||
|
|
||||||
wipeAllData() {
|
wipeAllData() {
|
||||||
this.history = [];
|
this.history = [];
|
||||||
|
this.readLog = [];
|
||||||
|
this.markers = [];
|
||||||
this.readingStats = { ...DEFAULT_READING_STATS };
|
this.readingStats = { ...DEFAULT_READING_STATS };
|
||||||
this.settings = { ...this.settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} };
|
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||||
}
|
|
||||||
|
|
||||||
markMangaCompleted(mangaId: number) {
|
|
||||||
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
|
|
||||||
if (!folder) return;
|
|
||||||
if (!folder.mangaIds.includes(mangaId))
|
|
||||||
folder.mangaIds = [...folder.mangaIds, mangaId];
|
|
||||||
}
|
|
||||||
|
|
||||||
unmarkMangaCompleted(mangaId: number) {
|
|
||||||
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
|
|
||||||
if (!folder) return;
|
|
||||||
folder.mangaIds = folder.mangaIds.filter(id => id !== mangaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
isCompleted(mangaId: number): boolean {
|
|
||||||
return this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds.includes(mangaId) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAndMarkCompleted(mangaId: number, chapters: Chapter[]) {
|
|
||||||
if (!chapters.length) return;
|
|
||||||
if (chapters.every(c => c.isRead)) this.markMangaCompleted(mangaId);
|
|
||||||
else this.unmarkMangaCompleted(mangaId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
linkManga(idA: number, idB: number) {
|
linkManga(idA: number, idB: number) {
|
||||||
@@ -356,7 +591,7 @@ class Store {
|
|||||||
this.settings = { ...this.settings, mangaLinks: links };
|
this.settings = { ...this.settings, mangaLinks: links };
|
||||||
}
|
}
|
||||||
|
|
||||||
getLinkedMangaIds(mangaId: number): number[] { return this.settings.mangaLinks[mangaId] ?? []; }
|
getLinkedMangaIds(mangaId: number): number[] { return this.settings.mangaLinks[mangaId] ?? []; }
|
||||||
|
|
||||||
setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
|
setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
|
||||||
const slots = [...(this.settings.heroSlots ?? [null, null, null, null])];
|
const slots = [...(this.settings.heroSlots ?? [null, null, null, null])];
|
||||||
@@ -368,109 +603,123 @@ class Store {
|
|||||||
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
|
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
||||||
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
setCategories(cats: Category[]) { this.categories = cats; }
|
||||||
setNavPage(next: NavPage) { this.navPage = next; }
|
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
||||||
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
|
setNavPage(next: NavPage) { this.navPage = next; }
|
||||||
setGenreFilter(next: string) { this.genreFilter = next; }
|
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
|
||||||
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
setGenreFilter(next: string) { this.genreFilter = next; }
|
||||||
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
||||||
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
||||||
setActiveSource(next: Source | null) { this.activeSource = next; }
|
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
||||||
setPageUrls(next: string[]) { this.pageUrls = next; }
|
setActiveSource(next: Source | null) { this.activeSource = next; }
|
||||||
setPageNumber(next: number) { this.pageNumber = next; }
|
setPageUrls(next: string[]) { this.pageUrls = next; }
|
||||||
setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; }
|
setPageNumber(next: number) { this.pageNumber = next; }
|
||||||
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; }
|
||||||
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
|
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
||||||
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
|
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
|
||||||
|
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
|
||||||
|
|
||||||
addFolder(name: string): string {
|
saveCustomTheme(theme: CustomTheme) {
|
||||||
const id = genId();
|
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
|
||||||
this.settings = { ...this.settings, folders: [...this.settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] };
|
const next = existing >= 0
|
||||||
return id;
|
? this.settings.customThemes.map((t, i) => i === existing ? theme : t)
|
||||||
|
: [...this.settings.customThemes, theme];
|
||||||
|
this.settings = { ...this.settings, customThemes: next };
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFolder(id: string) {
|
deleteCustomTheme(id: string) {
|
||||||
this.settings = { ...this.settings, folders: this.settings.folders.filter(f => f.id !== id || f.system) };
|
const next = this.settings.customThemes.filter(t => t.id !== id);
|
||||||
|
const wasActive = this.settings.theme === id;
|
||||||
|
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
|
||||||
}
|
}
|
||||||
|
|
||||||
renameFolder(id: string, name: string) {
|
async checkAndMarkCompleted(
|
||||||
this.settings = {
|
mangaId: number,
|
||||||
...this.settings,
|
chaps: Chapter[],
|
||||||
folders: this.settings.folders.map(f => f.id === id && !f.system ? { ...f, name: name.trim() } : f),
|
categories: Category[],
|
||||||
};
|
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||||
|
UPDATE_MANGA_CATEGORIES: string,
|
||||||
|
UPDATE_MANGA?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!chaps.length) return;
|
||||||
|
const allRead = chaps.every(c => c.isRead);
|
||||||
|
const completed = categories.find(c => c.name === "Completed");
|
||||||
|
if (!completed) return;
|
||||||
|
if (allRead) {
|
||||||
|
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error);
|
||||||
|
if (UPDATE_MANGA) {
|
||||||
|
await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [], removeFrom: [completed.id] }).catch(console.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFolderTab(id: string) {
|
toggleHiddenCategory(id: number) {
|
||||||
this.settings = {
|
const ids = this.settings.hiddenCategoryIds ?? [];
|
||||||
...this.settings,
|
const next = ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id];
|
||||||
folders: this.settings.folders.map(f => f.id === id ? { ...f, showTab: !f.showTab } : f),
|
this.settings = { ...this.settings, hiddenCategoryIds: next };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assignMangaToFolder(folderId: string, mangaId: number) {
|
clearDiscoverCache() {
|
||||||
this.settings = {
|
this.discoverCache = new Map();
|
||||||
...this.settings,
|
this.discoverLibraryIds = new Set();
|
||||||
folders: this.settings.folders.map(f =>
|
this.discoverSrcOffset++;
|
||||||
f.id === folderId && !f.mangaIds.includes(mangaId)
|
|
||||||
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
|
|
||||||
: f
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
removeMangaFromFolder(folderId: string, mangaId: number) {
|
|
||||||
this.settings = {
|
|
||||||
...this.settings,
|
|
||||||
folders: this.settings.folders.map(f =>
|
|
||||||
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) } : f
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getMangaFolders(mangaId: number): Folder[] {
|
|
||||||
return this.settings.folders.filter(f => f.mangaIds.includes(mangaId));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const store = new Store();
|
export const store = new Store();
|
||||||
|
|
||||||
// ── Function re-exports — zero call-site changes for actions ──────────────────
|
export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); }
|
||||||
|
export function closeReader() { store.closeReader(); }
|
||||||
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
|
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
|
||||||
export function closeReader() { store.closeReader(); }
|
export function clearHistory() { store.clearHistory(); }
|
||||||
export function addHistory(entry: HistoryEntry) { store.addHistory(entry); }
|
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
||||||
export function clearHistory() { store.clearHistory(); }
|
export function wipeAllData() { store.wipeAllData(); }
|
||||||
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
||||||
export function wipeAllData() { store.wipeAllData(); }
|
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
|
||||||
export function markMangaCompleted(mangaId: number) { store.markMangaCompleted(mangaId); }
|
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
|
||||||
export function unmarkMangaCompleted(mangaId: number) { store.unmarkMangaCompleted(mangaId); }
|
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
||||||
export function isCompleted(mangaId: number) { return store.isCompleted(mangaId); }
|
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
||||||
export function checkAndMarkCompleted(mangaId: number, c: Chapter[]) { store.checkAndMarkCompleted(mangaId, c); }
|
export function dismissToast(id: string) { store.dismissToast(id); }
|
||||||
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
export function setCategories(cats: Category[]) { store.setCategories(cats); }
|
||||||
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
|
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
||||||
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
|
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
||||||
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
||||||
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
export function setGenreFilter(next: string) { store.setGenreFilter(next); }
|
||||||
export function dismissToast(id: string) { store.dismissToast(id); }
|
export function setSearchPrefill(next: string) { store.setSearchPrefill(next); }
|
||||||
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
export function setActiveManga(next: Manga | null) { store.setActiveManga(next); }
|
||||||
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); }
|
||||||
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
export function setActiveSource(next: Source | null) { store.setActiveSource(next); }
|
||||||
export function setGenreFilter(next: string) { store.setGenreFilter(next); }
|
export function setPageUrls(next: string[]) { store.setPageUrls(next); }
|
||||||
export function setSearchPrefill(next: string) { store.setSearchPrefill(next); }
|
export function setPageNumber(next: number) { store.setPageNumber(next); }
|
||||||
export function setActiveManga(next: Manga | null) { store.setActiveManga(next); }
|
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
||||||
export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); }
|
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
||||||
export function setActiveSource(next: Source | null) { store.setActiveSource(next); }
|
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||||
export function setPageUrls(next: string[]) { store.setPageUrls(next); }
|
export function resetKeybinds() { store.resetKeybinds(); }
|
||||||
export function setPageNumber(next: number) { store.setPageNumber(next); }
|
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
||||||
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
|
||||||
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
|
||||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
export function clearBookmarks() { store.clearBookmarks(); }
|
||||||
export function resetKeybinds() { store.resetKeybinds(); }
|
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
|
||||||
export function addFolder(name: string) { return store.addFolder(name); }
|
export function addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { return store.addMarker(entry); }
|
||||||
export function removeFolder(id: string) { store.removeFolder(id); }
|
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { store.updateMarker(id, patch); }
|
||||||
export function renameFolder(id: string, name: string) { store.renameFolder(id, name); }
|
export function removeMarker(id: string) { store.removeMarker(id); }
|
||||||
export function toggleFolderTab(id: string) { store.toggleFolderTab(id); }
|
export function getMarkersForPage(chapterId: number, page: number) { return store.getMarkersForPage(chapterId, page); }
|
||||||
export function assignMangaToFolder(folderId: string, mangaId: number) { store.assignMangaToFolder(folderId, mangaId); }
|
export function getMarkersForChapter(chapterId: number) { return store.getMarkersForChapter(chapterId); }
|
||||||
export function removeMangaFromFolder(folderId: string, mangaId: number) { store.removeMangaFromFolder(folderId, mangaId); }
|
export function getMarkersForManga(mangaId: number) { return store.getMarkersForManga(mangaId); }
|
||||||
export function getMangaFolders(mangaId: number) { return store.getMangaFolders(mangaId); }
|
export function clearMarkersForManga(mangaId: number) { store.clearMarkersForManga(mangaId); }
|
||||||
|
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
|
||||||
|
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
|
||||||
|
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
|
||||||
|
export async function checkAndMarkCompleted(
|
||||||
|
mangaId: number,
|
||||||
|
chaps: Chapter[],
|
||||||
|
categories: Category[],
|
||||||
|
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||||
|
UPDATE_MANGA_CATEGORIES: string,
|
||||||
|
UPDATE_MANGA?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||||
|
}
|
||||||
|
|||||||