Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c573c54318 | |||
| ff5fcc4fc0 | |||
| 64f63ceaa2 | |||
| 6d835914ef | |||
| 10f5936dbd | |||
| 5ddbfdbd6d | |||
| 0ff148f720 | |||
| d98ca76036 | |||
| 35650481b0 | |||
| 8b16537c35 | |||
| 96639d2152 | |||
| 1c135a79ca | |||
| 6c11a9d53e | |||
| 5a2f88b806 | |||
| 75430305e6 | |||
| ea76b5fc26 | |||
| d5d9ff8b6e | |||
| 7c9182eb4b | |||
| 4d6ebe8804 | |||
| 49562c3f76 | |||
| 4a299f60ac | |||
| de397f2462 | |||
| af29cffdff | |||
| f840ae6413 | |||
| 6b8d4fc05f | |||
| 15079f7755 | |||
| 1a08d2415f | |||
| 7917491389 | |||
| 0b6e9fbbbb | |||
| 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')
|
||||||
@@ -99,16 +99,16 @@ exec /usr/lib/moku/jre/bin/java \
|
|||||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
install -Dm644 packaging/dev.moku.app.desktop \
|
install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \
|
||||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop"
|
||||||
install -Dm644 src-tauri/icons/32x32.png \
|
install -Dm644 src-tauri/icons/32x32.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.app.png"
|
||||||
install -Dm644 src-tauri/icons/128x128.png \
|
install -Dm644 src-tauri/icons/128x128.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.app.png"
|
||||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png"
|
||||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
|
||||||
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,118 @@
|
|||||||
<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-TagSearch.png" width="49%" alt="TagSearch" />
|
||||||
|
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
||||||
|
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||||
|
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
||||||
|
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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
|
||||||
|
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, A–Z, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
|
||||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||||
|
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
||||||
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
|
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
||||||
|
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
||||||
|
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||||
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
|
- **Auto-updates** — in-app update checker with silent background notifications
|
||||||
|
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 +126,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).
|
||||||
@@ -68,4 +148,4 @@ Distributed under the [Apache 2.0 License](./LICENSE).
|
|||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
|
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
|
||||||
@@ -1,104 +1,45 @@
|
|||||||
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)
|
||||||
|
|
||||||
Bugs:
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
|
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||||
- Add Back after Search & Clear on Search
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
|
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
- Adjustment in Settings for Theme Editor:
|
||||||
|
- Patch Color-Picker to Work Properly
|
||||||
|
- Integrate Download Directory Changes (Settings)
|
||||||
- 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
|
|
||||||
- - Reader appears to be adding integers? Marks chapters incorrectly, need to stablize and patch. User is able to
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
- 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:
|
Priority Bugs:
|
||||||
|
- Fix Library Build not Updating
|
||||||
- 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)
|
- Loading Buffer for Pictures (Due to Auth Lag)
|
||||||
- Fix the Mark as Read (Glitched)
|
|
||||||
|
|
||||||
|
|
||||||
Completed:
|
General/Misc Bugs:
|
||||||
8. Fix Polling on Download Manager (Instantanous Response)
|
- Fix Highlightable Elements
|
||||||
19. Debounce Time on Reader to improve lag (Toggle Setting)
|
- Investigate "egl:failed to create dri2 screen"
|
||||||
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
|
- Check Fonts/Design on Flatpak
|
||||||
17. Change Library Text change to "No manga saved to library, browse sources to add some."
|
- Fix Delete-All Crash (Deletes All but Cripples App)
|
||||||
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
|
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
||||||
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)
|
In-Progress:
|
||||||
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
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)
|
- Working on 3D Display Cards
|
||||||
- Add Refresh Details on Series Details.
|
- Chapter refresh Notification Looks bad (Series Detail)
|
||||||
- Patch GenreDrill & Integrate into Explore Folder
|
|
||||||
18. Disable NSFW Extensions option in settings
|
- Fix Discover Workout
|
||||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
- Fix CSS on Saved State for Search
|
||||||
- Remove Series Detail Mark Read & Unread
|
- Fix State & Cache names (Mapped to Discover hence needs Renaming)
|
||||||
20. Expand History (Total Time Read, etc)
|
- Completely Remove Discover
|
||||||
12. Delete all Downloads should also cancel all download queues
|
|
||||||
13. Cancel Download along with Queue & Download Timeout Feature
|
- Add Small QOL Animations where Appropriate
|
||||||
- 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)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Testing:
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -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.5 MiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 947 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 5.0 MiB |
|
After Width: | Height: | Size: 940 KiB |
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem = { system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.4.0";
|
version = "0.8.0";
|
||||||
|
|
||||||
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"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
@@ -176,7 +177,7 @@ EOF
|
|||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
MANIFEST="$REPO/dev.moku.app.yml"
|
MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
|
||||||
|
|
||||||
echo "── Bumping versions ──"
|
echo "── Bumping versions ──"
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||||
@@ -225,7 +226,7 @@ EOF
|
|||||||
--force-clean \
|
--force-clean \
|
||||||
"$REPO/build-dir" \
|
"$REPO/build-dir" \
|
||||||
"$MANIFEST"
|
"$MANIFEST"
|
||||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" dev.moku.app
|
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.Youwes09.Moku
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
echo "moku.flatpak created"
|
echo "moku.flatpak created"
|
||||||
|
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
app-id: dev.moku.app
|
app-id: io.github.Youwes09.Moku
|
||||||
runtime: org.gnome.Platform
|
runtime: org.gnome.Platform
|
||||||
runtime-version: '48'
|
runtime-version: '48'
|
||||||
sdk: org.gnome.Sdk
|
sdk: org.gnome.Sdk
|
||||||
@@ -9,16 +9,22 @@ separate-locales: false
|
|||||||
|
|
||||||
finish-args:
|
finish-args:
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --socket=x11
|
|
||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
- --share=ipc
|
- --share=ipc
|
||||||
- --device=dri
|
- --device=dri
|
||||||
- --share=network
|
- --share=network
|
||||||
- --socket=session-bus
|
|
||||||
- --socket=system-bus
|
- --talk-name=org.freedesktop.Notifications
|
||||||
- --filesystem=home
|
- --talk-name=org.freedesktop.portal.Desktop
|
||||||
|
- --talk-name=org.freedesktop.portal.FileTransfer
|
||||||
|
|
||||||
|
- --talk-name=org.kde.StatusNotifierWatcher
|
||||||
|
- --talk-name=com.canonical.AppMenu.Registrar
|
||||||
|
- --talk-name=com.canonical.indicator.application
|
||||||
|
|
||||||
|
- --filesystem=xdg-run/discord-ipc-0:ro
|
||||||
- --filesystem=xdg-data/moku:create
|
- --filesystem=xdg-data/moku:create
|
||||||
- --talk-name=org.freedesktop.Flatpak
|
- --filesystem=xdg-download
|
||||||
|
|
||||||
build-options:
|
build-options:
|
||||||
append-path: /usr/lib/sdk/rust-stable/bin
|
append-path: /usr/lib/sdk/rust-stable/bin
|
||||||
@@ -33,13 +39,10 @@ modules:
|
|||||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
|
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
|
||||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
|
||||||
dest-filename: jdk.tar.gz
|
dest-filename: jdk.tar.gz
|
||||||
|
|
||||||
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
|
|
||||||
# exits just that thread instead of killing the whole JVM. Official Suwayomi
|
|
||||||
# fix for headless environments. Source inlined to avoid upstream drift.
|
|
||||||
- name: catch-abort
|
- name: catch-abort
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -120,7 +123,6 @@ modules:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
||||||
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
@@ -138,8 +140,6 @@ modules:
|
|||||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
|
|
||||||
# that thread instead of crashing the whole JVM process.
|
|
||||||
export LD_PRELOAD="/app/lib/catch_abort.so"
|
export LD_PRELOAD="/app/lib/catch_abort.so"
|
||||||
|
|
||||||
exec /app/jre/bin/java \
|
exec /app/jre/bin/java \
|
||||||
@@ -171,17 +171,19 @@ modules:
|
|||||||
- tar -xzf frontend-dist.tar.gz
|
- tar -xzf frontend-dist.tar.gz
|
||||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||||
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
|
- install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop
|
||||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.png
|
||||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
- install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
- type: dir
|
- type: git
|
||||||
path: .
|
url: https://github.com/Youwes09/Moku.git
|
||||||
|
tag: v0.8.0
|
||||||
|
commit: ff5fcc4fc0dd97e187fac15480406993bc4231da
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: c78a3f002f898011c4e70e1af781b37dac0fd995b5623170256d88339c90ca74
|
sha256: f21034da4b8da42d8084978b60e429162aabb28808fa019ffb786e877c4ae95b
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<component type="desktop-application">
|
|
||||||
<id>dev.moku.app</id>
|
|
||||||
<metadata_license>MIT</metadata_license>
|
|
||||||
<project_license>MIT</project_license>
|
|
||||||
|
|
||||||
<name>Moku</name>
|
|
||||||
<summary>Manga reader powered by Suwayomi</summary>
|
|
||||||
|
|
||||||
<description>
|
|
||||||
<p>
|
|
||||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
|
||||||
providing a clean native interface for browsing, reading, and managing your
|
|
||||||
manga library across hundreds of sources.
|
|
||||||
</p>
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
|
|
||||||
|
|
||||||
<url type="homepage">https://github.com/shozikan/Moku</url>
|
|
||||||
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
|
|
||||||
|
|
||||||
<provides>
|
|
||||||
<binary>moku</binary>
|
|
||||||
</provides>
|
|
||||||
|
|
||||||
<content_rating type="oars-1.1" />
|
|
||||||
|
|
||||||
<releases>
|
|
||||||
<release version="0.4.0" date="2025-03-22">
|
|
||||||
<description>
|
|
||||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
</releases>
|
|
||||||
</component>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Name=Moku
|
Name=Moku
|
||||||
Comment=Manga reader powered by Suwayomi
|
Comment=Manga reader powered by Suwayomi
|
||||||
Exec=moku
|
Exec=moku
|
||||||
Icon=dev.moku.app
|
Icon=io.github.Youwes09.Moku
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Graphics;Viewer;
|
Categories=Graphics;Viewer;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>io.github.Youwes09.Moku</id>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
|
||||||
|
<name>Moku</name>
|
||||||
|
<summary>Manga reader powered by Suwayomi</summary>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||||
|
providing a clean native interface for browsing, reading, and managing your
|
||||||
|
manga library across hundreds of sources.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Features include library management, chapter tracking, extension support,
|
||||||
|
reading history, notifications, and Discord Rich Presence integration.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable>
|
||||||
|
|
||||||
|
<url type="homepage">https://github.com/Youwes09/Moku</url>
|
||||||
|
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||||
|
<caption>Home screen showing your manga library</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||||
|
<caption>Built-in manga reader</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||||
|
<caption>Discover new manga across hundreds of sources</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||||
|
<caption>Download manager</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||||
|
<caption>Settings</caption>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>moku</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.8.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Latest release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.4.0" date="2025-03-22">
|
||||||
|
<description>
|
||||||
|
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
@@ -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.8.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -15,17 +15,26 @@ 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-dialog = "2"
|
||||||
dirs = "5"
|
tauri-plugin-os = "2.3.2"
|
||||||
|
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"
|
||||||
@@ -186,7 +272,7 @@ fn suwayomi_data_dir() -> PathBuf {
|
|||||||
{
|
{
|
||||||
dirs::data_dir()
|
dirs::data_dir()
|
||||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||||
.join("dev.moku.app/tachidesk")
|
.join("io.github.Youwes09.Moku.app/tachidesk")
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
@@ -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,43 @@ 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() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
Ok(p) => {
|
if let Some(bin_dir) = exe.parent() {
|
||||||
let stripped = strip_unc(p);
|
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||||
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
|
let p = bin_dir.join(name);
|
||||||
stripped
|
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
|
||||||
}
|
if p.exists() {
|
||||||
Err(e) => {
|
return Ok(ServerInvocation {
|
||||||
let msg = format!("resource_dir error: {e}");
|
bin: p.to_string_lossy().into_owned(),
|
||||||
do_log(log, &format!("[resolve] ERROR: {}", msg));
|
args: vec![],
|
||||||
return Err(SpawnError::SpawnFailed(msg));
|
working_dir: Some(bin_dir.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let resource_dir = {
|
||||||
|
let raw = app.path().resource_dir().unwrap_or_default();
|
||||||
|
let stripped = strip_unc(raw);
|
||||||
|
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
|
||||||
|
stripped
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
@@ -257,76 +356,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 +461,30 @@ 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();
|
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||||
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.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
|
|
||||||
let working_dir = invocation.working_dir
|
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
|
||||||
.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] 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 +492,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 +510,146 @@ 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_path(path: String) -> Result<(), String> {
|
||||||
|
let p = std::path::Path::new(path.trim());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
std::process::Command::new("explorer")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
std::process::Command::new("open")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
std::process::Command::new("xdg-open")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Downloads Folder")
|
||||||
|
.blocking_pick_folder()
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[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_dialog::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,
|
||||||
|
open_path,
|
||||||
|
pick_downloads_folder,
|
||||||
])
|
])
|
||||||
.setup(|_app| Ok(()))
|
.setup(|_app| Ok(()))
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
@@ -434,4 +659,4 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku");
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.4.0",
|
"version": "0.8.0",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "io.github.Youwes09.Moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build"
|
"beforeBuildCommand": "pnpm build"
|
||||||
@@ -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,110 @@
|
|||||||
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 { platform } from "@tauri-apps/plugin-os";
|
||||||
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();
|
||||||
|
const isWindows = platform() === "windows";
|
||||||
|
|
||||||
|
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 +134,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,32 +151,142 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$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)
|
|
||||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
let paused = false;
|
||||||
|
|
||||||
|
const poll = () => {
|
||||||
|
if (paused) return;
|
||||||
|
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
|
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
poll();
|
poll();
|
||||||
pollInterval = setInterval(poll, 2000);
|
pollInterval = setInterval(poll, 2000);
|
||||||
return () => clearInterval(pollInterval);
|
|
||||||
|
const onVisibility = () => { paused = document.hidden; };
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
|
||||||
|
let unlistenFocus: (() => void) | undefined;
|
||||||
|
win.onFocusChanged(({ payload: focused }) => {
|
||||||
|
paused = !focused;
|
||||||
|
}).then(fn => { unlistenFocus = fn; });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
unlistenFocus?.();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 +297,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 +315,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 +473,26 @@
|
|||||||
<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 { 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,12 @@
|
|||||||
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 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,15 +23,13 @@
|
|||||||
{: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}
|
|
||||||
<GenreDrillPage />
|
|
||||||
{:else if store.navPage === "explore" || store.navPage === "sources"}
|
|
||||||
<Discover />
|
|
||||||
{:else if store.navPage === "downloads"}
|
{:else if store.navPage === "downloads"}
|
||||||
<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}
|
||||||
@@ -42,4 +39,4 @@
|
|||||||
<style>
|
<style>
|
||||||
.root { display: flex; height: 100%; background: var(--bg-base); overflow: hidden; }
|
.root { display: flex; height: 100%; background: var(--bg-base); overflow: hidden; }
|
||||||
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; }
|
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; }
|
||||||
</style>
|
</style>
|
||||||
@@ -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, 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";
|
||||||
|
|
||||||
@@ -8,16 +8,16 @@
|
|||||||
{ id: "library", label: "Library", icon: Books },
|
{ id: "library", label: "Library", icon: Books },
|
||||||
{ id: "search", label: "Search", icon: MagnifyingGlass },
|
{ id: "search", label: "Search", icon: MagnifyingGlass },
|
||||||
{ id: "history", label: "History", icon: ClockCounterClockwise },
|
{ id: "history", label: "History", icon: ClockCounterClockwise },
|
||||||
{ 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) {
|
||||||
store.navPage = id;
|
store.navPage = id;
|
||||||
store.activeManga = null;
|
store.activeManga = null;
|
||||||
store.genreFilter = "";
|
store.activeSource = null;
|
||||||
if (id !== "explore") store.activeSource = null;
|
store.genreFilter = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
@@ -49,20 +49,21 @@
|
|||||||
</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; }
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
<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, paused = false;
|
||||||
|
|
||||||
|
function frame(now: number) {
|
||||||
|
if (paused) { raf = 0; return; }
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
paused = true;
|
||||||
|
t0 = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resume() {
|
||||||
|
if (!paused) return;
|
||||||
|
paused = false;
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibility() {
|
||||||
|
document.hidden ? pause() : resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
const unlistenFocus = win.onFocusChanged(({ payload: focused }) => {
|
||||||
|
focused ? resume() : pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
ro.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
unlistenFocus.then(f => f());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</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,189 @@
|
|||||||
|
<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(() => {
|
||||||
|
const activeIds = new Set(store.toasts.map(t => t.id));
|
||||||
|
store.toasts.forEach(schedule);
|
||||||
|
for (const [id, timer] of timers) {
|
||||||
|
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
<p class="sub">{t.body ?? '\u00a0'}</p>
|
||||||
|
</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: 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px var(--sp-3) 12px 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;
|
||||||
|
width: 280px;
|
||||||
|
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: -5px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-bar {
|
||||||
|
width: 3px;
|
||||||
|
align-self: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
line-height: 1;
|
||||||
|
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,462 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
|
||||||
import { store, addFolder, assignMangaToFolder, setPreviewManga } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Source } from "../../lib/types";
|
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
|
||||||
const GRID_LIMIT = 60; // max rendered per tab
|
|
||||||
const LOCAL_THRESHOLD = 20; // fan out to sources if local results below this
|
|
||||||
const CONCURRENCY = 4; // parallel source requests — kept conservative to not saturate connections
|
|
||||||
const BATCH_INTERVAL = 400; // ms between DOM updates during background source fan-out
|
|
||||||
|
|
||||||
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 = `
|
|
||||||
query MangasByGenre($genre: String!, $first: Int) {
|
|
||||||
mangas(
|
|
||||||
filter: { genre: { includesInsensitive: $genre } }
|
|
||||||
first: $first orderBy: IN_LIBRARY_AT orderByType: DESC
|
|
||||||
) { nodes { id title thumbnailUrl inLibrary genre status source { id displayName } } }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
|
||||||
let allManga: Manga[] = $state([]); // local library — loaded once, never triggers lag
|
|
||||||
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.
|
|
||||||
// "All" key → local library deduped by title
|
|
||||||
// Each tab key → local + background source results, deduped id+title
|
|
||||||
let genreResults = $state(new Map<string, Manga[]>());
|
|
||||||
let genreLoading = $state(false); // true only during the initial local fetch for a new tab
|
|
||||||
let currentGenre = $state("All");
|
|
||||||
let genreAbort: AbortController | null = null;
|
|
||||||
|
|
||||||
// batch timer handle for background source fan-out
|
|
||||||
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 isLoading = $state(false);
|
|
||||||
|
|
||||||
// ── Derived ───────────────────────────────────────────────────────────────────
|
|
||||||
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[] {
|
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
|
|
||||||
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
|
|
||||||
let i = 0;
|
|
||||||
const worker = async () => {
|
|
||||||
while (i < items.length) {
|
|
||||||
if (signal.aborted) return;
|
|
||||||
await fn(items[i++]).catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Batched DOM flush ─────────────────────────────────────────────────────────
|
|
||||||
// Source fan-out collects results in batchAccum. A timer fires every BATCH_INTERVAL
|
|
||||||
// ms and flushes them into genreResults in one shot — preventing a Svelte re-render
|
|
||||||
// per-source and keeping the grid smooth.
|
|
||||||
function startBatchFlush() {
|
|
||||||
if (batchTimer) return;
|
|
||||||
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() {
|
|
||||||
if (batchTimer) { clearInterval(batchTimer); batchTimer = null; }
|
|
||||||
// Final flush of anything remaining
|
|
||||||
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)
|
|
||||||
function accumulate(genre: string, mangas: Manga[]) {
|
|
||||||
const existing = batchAccum.get(genre) ?? [];
|
|
||||||
batchAccum.set(genre, [...existing, ...mangas]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, [genre]);
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Only accumulate results that actually match the genre (client-side AND check)
|
|
||||||
const matching = result.mangas.filter(m =>
|
|
||||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
|
||||||
|| result.mangas.length <= 5 // source returns few results, trust them
|
|
||||||
);
|
|
||||||
|
|
||||||
accumulate(genre, matching.length > 0 ? matching : result.mangas);
|
|
||||||
}, 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) {
|
|
||||||
if (currentGenre === genre) return;
|
|
||||||
|
|
||||||
// Abort any in-flight fan-out for the previous tab
|
|
||||||
genreAbort?.abort();
|
|
||||||
stopBatchFlush();
|
|
||||||
|
|
||||||
currentGenre = genre;
|
|
||||||
|
|
||||||
if (genre === "All") {
|
|
||||||
// "All" is just the deduped local library — no network needed
|
|
||||||
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we already have a fully-populated cache for this genre, show it instantly
|
|
||||||
const cached = genreResults.get(genre);
|
|
||||||
if (cached && cached.length >= LOCAL_THRESHOLD) return;
|
|
||||||
|
|
||||||
// Fetch local results (fast — single DB query)
|
|
||||||
genreLoading = true;
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
genreAbort = ctrl;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const localData = await cache.get(CACHE_KEYS.GENRE(genre), () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal)
|
|
||||||
.then(d => d.mangas.nodes)
|
|
||||||
);
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const local = dedup(localData);
|
|
||||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
genreLoading = false;
|
|
||||||
|
|
||||||
// If sparse, fan out to sources in the background — no loading state shown
|
|
||||||
if (local.length < LOCAL_THRESHOLD) {
|
|
||||||
fanOutSources(genre, ctrl).catch(() => {}); // fully detached background task
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Context menu ──────────────────────────────────────────────────────────────
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
|
||||||
}
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
...(store.settings.folders.length > 0 ? [
|
|
||||||
{ separator: true } as MenuEntry,
|
|
||||||
...store.settings.folders.map(f => ({
|
|
||||||
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 n = prompt("Folder name:");
|
|
||||||
if (n?.trim()) { const id = addFolder(n.trim()); assignMangaToFolder(id, m.id); }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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>
|
|
||||||
|
|
||||||
<!-- ── Source browse passthrough ─────────────────────────────────────────────── -->
|
|
||||||
{#if store.activeSource}
|
|
||||||
<SourceBrowse />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<!-- ── Header: page label + genre pill tabs ──────────────────────────────── -->
|
|
||||||
<div class="header">
|
|
||||||
<span class="heading">Discover</span>
|
|
||||||
<div class="tab-strip">
|
|
||||||
{#each GENRE_TABS as tab (tab)}
|
|
||||||
<button
|
|
||||||
class="genre-tab"
|
|
||||||
class:active={currentGenre === tab}
|
|
||||||
onclick={() => switchGenre(tab)}
|
|
||||||
>
|
|
||||||
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Body ──────────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="body">
|
|
||||||
|
|
||||||
{#if isLoading}
|
|
||||||
<!-- Skeleton — shown only during first local fetch, never during bg fan-out -->
|
|
||||||
<div class="manga-grid">
|
|
||||||
{#each Array(24) as _, i (i)}
|
|
||||||
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if loadError && visibleGrid.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<span>Could not reach Suwayomi</span>
|
|
||||||
<button class="retry-btn" onclick={loadAll}>Retry</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if visibleGrid.length === 0}
|
|
||||||
<div class="empty"><span>Nothing found for "{currentGenre}"</span></div>
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
<div class="manga-grid">
|
|
||||||
{#each visibleGrid as m (m.id)}
|
|
||||||
<button
|
|
||||||
class="manga-card"
|
|
||||||
onclick={() => setPreviewManga(m)}
|
|
||||||
oncontextmenu={(e) => openCtx(e, m)}
|
|
||||||
>
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img
|
|
||||||
src={thumbUrl(m.thumbnailUrl)} alt={m.title}
|
|
||||||
class="cover" loading="lazy" decoding="async"
|
|
||||||
/>
|
|
||||||
<div class="cover-gradient"></div>
|
|
||||||
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
|
||||||
<div class="card-footer">
|
|
||||||
<p class="card-title">{m.title}</p>
|
|
||||||
{#if m.source?.displayName}
|
|
||||||
<p class="card-source">{m.source.displayName}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</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 ──────────────────────────────────────────────────────────────── */
|
|
||||||
.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; }
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Genre pill tabs */
|
|
||||||
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
/* ── Body ────────────────────────────────────────────────────────────────── */
|
|
||||||
.body {
|
|
||||||
flex: 1; overflow-y: auto;
|
|
||||||
padding: var(--sp-4) var(--sp-5) var(--sp-6);
|
|
||||||
/* GPU-accelerated scroll — does NOT promote every card, only the scroll container */
|
|
||||||
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; }
|
|
||||||
/* Promote only the hovered card to its own GPU layer */
|
|
||||||
.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 {
|
|
||||||
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; }
|
|
||||||
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
|
|
||||||
/* ── Empty / error ───────────────────────────────────────────────────────── */
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
.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 } }
|
|
||||||
</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";
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
let refreshing = $state(false);
|
let refreshing = $state(false);
|
||||||
let filter: Filter = $state("installed");
|
let filter: Filter = $state("installed");
|
||||||
let search = $state("");
|
let search = $state("");
|
||||||
|
let langFilter = $state<string | null>(null);
|
||||||
let working = $state(new Set<string>());
|
let working = $state(new Set<string>());
|
||||||
let expanded = $state(new Set<string>());
|
let expanded = $state(new Set<string>());
|
||||||
let panel: Panel = $state(null);
|
let panel: Panel = $state(null);
|
||||||
@@ -99,9 +101,17 @@
|
|||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
||||||
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
|
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
|
||||||
return matchSearch && matchFilter;
|
const matchLang = langFilter === null || e.lang === langFilter;
|
||||||
|
return matchSearch && matchFilter && matchLang;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const availableLangs = $derived(
|
||||||
|
[...new Set(extensions
|
||||||
|
.filter((e) => filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true)
|
||||||
|
.map((e) => e.lang)
|
||||||
|
)].sort()
|
||||||
|
);
|
||||||
|
|
||||||
const groups = $derived.by(() => {
|
const groups = $derived.by(() => {
|
||||||
const map = new Map<string, Extension[]>();
|
const map = new Map<string, Extension[]>();
|
||||||
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
|
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
|
||||||
@@ -120,6 +130,8 @@
|
|||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function setFilter(f: Filter) { filter = f; langFilter = null; }
|
||||||
|
|
||||||
function toggleExpand(base: string) {
|
function toggleExpand(base: string) {
|
||||||
const next = new Set(expanded);
|
const next = new Set(expanded);
|
||||||
next.has(base) ? next.delete(base) : next.add(base);
|
next.has(base) ? next.delete(base) : next.add(base);
|
||||||
@@ -130,12 +142,23 @@
|
|||||||
<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={() => setFilter(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" />
|
<Plus size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
|
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
|
||||||
<Plus size={14} weight="light" />
|
<GitBranch size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||||
@@ -143,6 +166,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if availableLangs.length > 1}
|
||||||
|
<div class="lang-bar">
|
||||||
|
<button class="lang-pill" class:active={langFilter === null} onclick={() => langFilter = null}>All</button>
|
||||||
|
{#each availableLangs as lang}
|
||||||
|
<button class="lang-pill" class:active={langFilter === lang} onclick={() => langFilter = langFilter === lang ? null : lang}>{lang.toUpperCase()}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if panel === "apk"}
|
{#if panel === "apk"}
|
||||||
<div class="ext-panel">
|
<div class="ext-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -153,7 +185,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 +233,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 +246,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 +302,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 +330,15 @@
|
|||||||
.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; }
|
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
|
||||||
.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); }
|
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
|
||||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); }
|
.lang-pill.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
|
.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); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
|
||||||
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
.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 +348,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 +370,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>
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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, Bell } 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, clearLibraryUpdates } 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,27 +33,42 @@
|
|||||||
|
|
||||||
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);
|
||||||
|
|
||||||
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 ?? [];
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||||
if (!missingIds.length) return;
|
)
|
||||||
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
.then(m => { libraryManga = m; })
|
||||||
const valid = results.flatMap(r => r.status === "fulfilled" && r.value ? [r.value] : []);
|
.catch(console.error)
|
||||||
if (valid.length) extraManga = valid;
|
.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());
|
||||||
|
});
|
||||||
|
|
||||||
const continueReading = $derived((() => {
|
const continueReading = $derived((() => {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
const out: HistoryEntry[] = [];
|
const out: HistoryEntry[] = [];
|
||||||
@@ -86,15 +104,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 +140,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 +174,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 +193,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 +212,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,11 +235,20 @@
|
|||||||
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 recentHistory = $derived(store.history.slice(0, 6));
|
||||||
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 recentHistory = $derived(store.history.slice(0, 8));
|
|
||||||
const stats = $derived(store.readingStats);
|
const stats = $derived(store.readingStats);
|
||||||
|
const libraryUpdates = $derived(store.libraryUpdates.slice(0, 7));
|
||||||
|
const lastRefresh = $derived(store.lastLibraryRefresh);
|
||||||
|
|
||||||
|
function timeAgoRefresh(ts: number): string {
|
||||||
|
if (!ts) return "";
|
||||||
|
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`;
|
||||||
|
return `${Math.floor(h / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
function handleRowWheel(e: WheelEvent) {
|
function handleRowWheel(e: WheelEvent) {
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||||
@@ -348,7 +406,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>
|
||||||
@@ -382,28 +440,31 @@
|
|||||||
<div class="bottom-row">
|
<div class="bottom-row">
|
||||||
<div class="bottom-col">
|
<div class="bottom-col">
|
||||||
<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"><Bell size={10} weight="bold" /> Updates
|
||||||
{#if completedManga.length > 0}
|
{#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
|
||||||
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
|
</span>
|
||||||
|
{#if libraryUpdates.length > 0}
|
||||||
|
<button class="see-all" onclick={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}>Clear <ArrowRight size={9} weight="bold" /></button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if completedManga.length > 0}
|
{#if libraryUpdates.length > 0}
|
||||||
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
||||||
{#each completedManga as m (m.id)}
|
{#each libraryUpdates as u (u.mangaId)}
|
||||||
<button class="mini-card" onclick={() => store.previewManga = m}>
|
{@const m = libraryManga.find(x => x.id === u.mangaId)}
|
||||||
|
<button class="mini-card" onclick={() => { if (m) 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={u.thumbnailUrl} alt={u.mangaTitle} 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">{u.mangaTitle}</p>
|
||||||
{#if m.source?.displayName}<p class="mini-card-source">{m.source.displayName}</p>{/if}
|
<p class="mini-card-source">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="bottom-empty">Finish a manga to see it here</p>
|
<p class="bottom-empty">{lastRefresh ? "No new chapters found" : "Check for updates in the library"}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -418,7 +479,7 @@
|
|||||||
<div class="stat-card"><div class="stat-icon-wrap stat-accent"><BookOpen size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalChaptersRead}</span><span class="stat-label">Chapters read</span></div></div>
|
<div class="stat-card"><div class="stat-icon-wrap stat-accent"><BookOpen size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalChaptersRead}</span><span class="stat-label">Chapters read</span></div></div>
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><Clock size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span><span class="stat-label">Read time</span></div></div>
|
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><Clock size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span><span class="stat-label">Read time</span></div></div>
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><TrendUp size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalMangaRead}</span><span class="stat-label">Series started</span></div></div>
|
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><TrendUp size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalMangaRead}</span><span class="stat-label">Series started</span></div></div>
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-green"><CheckCircle size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{completedIds.length}</span><span class="stat-label">Completed</span></div></div>
|
<div class="stat-card"><div class="stat-icon-wrap stat-green"><Bell size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{libraryUpdates.length}</span><span class="stat-label">New updates</span></div></div>
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.longestStreakDays}d</span><span class="stat-label">Best streak</span></div></div>
|
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.longestStreakDays}d</span><span class="stat-label">Best streak</span></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,7 +489,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 +509,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 +599,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 +612,15 @@
|
|||||||
.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); }
|
.refresh-age { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
|
||||||
|
.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 +659,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, addBookmark } 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,18 +83,45 @@
|
|||||||
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;
|
||||||
const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
const asc = [...chapters]; // already sorted by sourceOrder from load()
|
||||||
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
const anyRead = asc.some(c => c.isRead);
|
||||||
const firstUnread = chapters.find((c) => !c.isRead);
|
|
||||||
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
const bookmark = displayManga
|
||||||
return { ch: chapters[0], label: "Read again" };
|
? store.bookmarks.find(b => b.mangaId === displayManga!.id)
|
||||||
|
: null;
|
||||||
|
if (bookmark) {
|
||||||
|
const ch = asc.find(c => c.id === bookmark.chapterId);
|
||||||
|
if (ch) {
|
||||||
|
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
|
||||||
|
const allRead = asc.every(c => c.isRead);
|
||||||
|
// If bookmarked chapter is the last one and everything is read,
|
||||||
|
// treat as fully finished — fall through to "reread"
|
||||||
|
if (!(isLastChapter && allRead)) {
|
||||||
|
return { ch, type: "continue" as const, resumePage: bookmark.pageNumber };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
|
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
|
||||||
|
const firstUnread = asc.find(c => !c.isRead);
|
||||||
|
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
|
||||||
|
return { ch: asc[0], type: "reread" as const, resumePage: null };
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
|
const continueLabel = $derived.by(() => {
|
||||||
|
if (!continueChapter) return "";
|
||||||
|
const { ch, type, resumePage } = continueChapter;
|
||||||
|
if (type === "reread") return "Read again";
|
||||||
|
if (type === "start") return `Start · Ch.${ch.chapterNumber}`;
|
||||||
|
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
$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 +202,68 @@
|
|||||||
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[]) {
|
||||||
|
const mangaStatus = (manga ?? displayManga)?.status;
|
||||||
|
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
|
||||||
|
// Sync local mangaCategories state after the mutation.
|
||||||
|
// Never auto-move an ONGOING series into Completed — user must do that manually.
|
||||||
|
const isOngoing = mangaStatus === "ONGOING";
|
||||||
|
if (chaps.length && !isOngoing) {
|
||||||
|
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);
|
||||||
|
if (!inCat && !inLibrary) {
|
||||||
|
await gql(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
|
||||||
|
if (manga) manga = { ...manga, inLibrary: true };
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
}
|
||||||
|
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: [] });
|
||||||
|
if (!inLibrary) {
|
||||||
|
await gql(UPDATE_MANGA, { id: store.previewManga.id, inLibrary: true }).catch(console.error);
|
||||||
|
if (manga) manga = { ...manga, inLibrary: true };
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
}
|
||||||
|
mangaCategories = [...mangaCategories, cat];
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
newFolderName = ""; creatingFolder = false;
|
newFolderName = ""; creatingFolder = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +289,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 +313,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,8 +397,25 @@
|
|||||||
<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={() => {
|
||||||
<Play size={12} weight="fill" />{continueChapter.label}
|
const { ch, type, resumePage } = continueChapter!;
|
||||||
|
if (type === "continue" && resumePage && resumePage > 1) {
|
||||||
|
const existing = store.bookmarks.find(b => b.chapterId === ch.id);
|
||||||
|
if (!existing || existing.pageNumber < resumePage) {
|
||||||
|
addBookmark({
|
||||||
|
mangaId: displayManga!.id,
|
||||||
|
mangaTitle: displayManga!.title,
|
||||||
|
thumbnailUrl: displayManga!.thumbnailUrl,
|
||||||
|
chapterId: ch.id,
|
||||||
|
chapterName: ch.name,
|
||||||
|
pageNumber: resumePage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openReader(ch, chapters, displayManga);
|
||||||
|
close();
|
||||||
|
}}>
|
||||||
|
<Play size={12} weight="fill" />{continueLabel}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if !loadingDetail}
|
{:else if !loadingDetail}
|
||||||
@@ -336,26 +444,25 @@
|
|||||||
{#if !loadingDetail && displayManga?.genre?.length}
|
{#if !loadingDetail && displayManga?.genre?.length}
|
||||||
<div class="genres">
|
<div class="genres">
|
||||||
{#each displayManga.genre as g}
|
{#each displayManga.genre as g}
|
||||||
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("explore"); close(); }}>{g}</button>
|
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("search"); close(); }}>{g}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#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>
|
||||||
@@ -373,7 +480,7 @@
|
|||||||
<button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
<button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
||||||
</div>
|
</div>
|
||||||
<p class="link-hint">
|
<p class="link-hint">
|
||||||
Mark two manga as the same series so duplicates are merged in search and discover.
|
Mark two manga as the same series so duplicates are merged in search.
|
||||||
Click a linked entry again to unlink.
|
Click a linked entry again to unlink.
|
||||||
</p>
|
</p>
|
||||||
<div class="link-search-wrap">
|
<div class="link-search-wrap">
|
||||||
@@ -388,7 +495,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 +520,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 +585,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 +606,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); }
|
||||||
@@ -506,4 +615,4 @@
|
|||||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
</style>
|
</style>
|
||||||
@@ -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,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: cls = "" }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Structure mirrors daisyUI hover-3d:
|
||||||
|
- :first-child (.hover-3d-content) → the card, gets rotate3d + scale
|
||||||
|
- :nth-child(2..9) → 8 invisible zone divs occupying the 3×3 grid
|
||||||
|
The wrapper IS the inline-grid, zones sit on top via z-index,
|
||||||
|
tilt is driven purely by CSS :has() — zero JS.
|
||||||
|
-->
|
||||||
|
<div class="hover-3d {cls}">
|
||||||
|
<div class="hover-3d-content">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
<!-- 8 zones: TL TC TR ML MR BL BC BR (no centre) -->
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hover-3d {
|
||||||
|
display: inline-grid;
|
||||||
|
perspective: 75rem;
|
||||||
|
--transform: 0, 0;
|
||||||
|
--shine: 100% 100%;
|
||||||
|
--shadow: 0rem 0rem 0rem;
|
||||||
|
--ease-out: linear(0, 0.931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);
|
||||||
|
--ease-hover: linear(0, 0.708 15.2%, 0.927 23.6%, 1.067 33%, 1.12 41%, 1.13 50.2%, 1.019 83.2%, 1);
|
||||||
|
filter:
|
||||||
|
drop-shadow(var(--shadow) 0.1rem #00000020)
|
||||||
|
drop-shadow(var(--shadow) 0.2rem #00000015)
|
||||||
|
drop-shadow(var(--shadow) 0.3rem #00000010);
|
||||||
|
transition: filter ease-out 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zone divs sit above the card content */
|
||||||
|
.hover-3d > :nth-child(n + 2) {
|
||||||
|
isolation: isolate;
|
||||||
|
z-index: 1;
|
||||||
|
scale: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3×3 grid positions for the 8 zones */
|
||||||
|
.hover-3d > :nth-child(2) { grid-area: 1/1/2/2; }
|
||||||
|
.hover-3d > :nth-child(3) { grid-area: 1/2/2/3; }
|
||||||
|
.hover-3d > :nth-child(4) { grid-area: 1/3/2/4; }
|
||||||
|
.hover-3d > :nth-child(5) { grid-area: 2/1/3/2; }
|
||||||
|
.hover-3d > :nth-child(6) { grid-area: 2/3/3/4; }
|
||||||
|
.hover-3d > :nth-child(7) { grid-area: 3/1/4/2; }
|
||||||
|
.hover-3d > :nth-child(8) { grid-area: 3/2/4/3; }
|
||||||
|
.hover-3d > :nth-child(9) { grid-area: 3/3/4/4; }
|
||||||
|
|
||||||
|
/* The card itself */
|
||||||
|
.hover-3d-content {
|
||||||
|
grid-area: 1/1/4/4;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
position: relative;
|
||||||
|
transform: rotate3d(var(--transform), 0, 10deg);
|
||||||
|
transition:
|
||||||
|
transform var(--ease-out) 500ms,
|
||||||
|
scale var(--ease-out) 500ms,
|
||||||
|
outline-color ease-out 500ms;
|
||||||
|
outline: 0.5px solid transparent;
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shine overlay */
|
||||||
|
.hover-3d-content::before {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(0.75rem);
|
||||||
|
background-image: radial-gradient(circle at 50%, rgba(255,255,255,0.18) 10%, transparent 50%);
|
||||||
|
translate: var(--shine);
|
||||||
|
transition:
|
||||||
|
translate ease-out 400ms,
|
||||||
|
opacity ease-out 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On hover: snappier ease, scale up, show shine + outline */
|
||||||
|
.hover-3d:hover {
|
||||||
|
--ease-out: var(--ease-hover);
|
||||||
|
}
|
||||||
|
.hover-3d:hover > .hover-3d-content {
|
||||||
|
scale: 1.05;
|
||||||
|
outline-color: rgba(255,255,255,0.07);
|
||||||
|
}
|
||||||
|
.hover-3d:hover > .hover-3d-content::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zone → rotate3d + shine + shadow mappings */
|
||||||
|
.hover-3d:has(> :nth-child(2):hover) { --transform: -1, 1; --shine: 0% 0%; --shadow: -0.5rem -0.5rem; }
|
||||||
|
.hover-3d:has(> :nth-child(3):hover) { --transform: -1, 0; --shine: 100% 0%; --shadow: 0rem -0.5rem; }
|
||||||
|
.hover-3d:has(> :nth-child(4):hover) { --transform: -1, -1; --shine: 200% 0%; --shadow: 0.5rem -0.5rem; }
|
||||||
|
.hover-3d:has(> :nth-child(5):hover) { --transform: 0, 1; --shine: 0% 100%; --shadow: -0.5rem 0rem; }
|
||||||
|
.hover-3d:has(> :nth-child(6):hover) { --transform: 0, -1; --shine: 200% 100%; --shadow: 0.5rem 0rem; }
|
||||||
|
.hover-3d:has(> :nth-child(7):hover) { --transform: 1, 1; --shine: 0% 200%; --shadow: -0.5rem 0.5rem; }
|
||||||
|
.hover-3d:has(> :nth-child(8):hover) { --transform: 1, 0; --shine: 100% 200%; --shadow: 0rem 0.5rem; }
|
||||||
|
.hover-3d:has(> :nth-child(9):hover) { --transform: 1, -1; --shine: 200% 200%; --shadow: 0.5rem 0.5rem; }
|
||||||
|
</style>
|
||||||
@@ -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,101 @@
|
|||||||
|
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: "omit",
|
||||||
|
headers: {
|
||||||
|
...(init.headers as Record<string, string> ?? {}),
|
||||||
|
...(user && pass ? basicHeader(user, pass) : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, { ...init, signal, credentials: "omit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||||
|
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "omit",
|
||||||
|
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: "omit",
|
||||||
|
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,7 +154,8 @@ 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",
|
||||||
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
|
CATEGORIES: "categories",
|
||||||
|
SEARCH: "search_all_manga", // Search's unfiltered fetch — separate from library
|
||||||
SOURCES: "sources",
|
SOURCES: "sources",
|
||||||
POPULAR: "popular",
|
POPULAR: "popular",
|
||||||
GENRE: (genre: string) => `genre:${genre}`,
|
GENRE: (genre: string) => `genre:${genre}`,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
@@ -100,4 +82,4 @@ export async function gql<T>(
|
|||||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||||
|
|
||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,104 @@
|
|||||||
|
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 = 6;
|
||||||
|
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 insertSorted(entry: QueueEntry) {
|
||||||
|
let lo = 0, hi = queue.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
|
if (queue[mid].priority > entry.priority) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
queue.splice(lo, 0, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain() {
|
||||||
|
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||||
|
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) => {
|
||||||
|
insertSorted({ 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 idx = queue.findIndex(e => e.url === url);
|
||||||
|
if (idx !== -1 && priority > queue[idx].priority) {
|
||||||
|
const [entry] = queue.splice(idx, 1);
|
||||||
|
entry.priority = priority;
|
||||||
|
insertSorted(entry);
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -1,45 +1,51 @@
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
export interface Keybinds {
|
export interface Keybinds {
|
||||||
pageRight: string;
|
turnPageRight: string;
|
||||||
pageLeft: string;
|
turnPageLeft: string;
|
||||||
firstPage: string;
|
firstPage: string;
|
||||||
lastPage: string;
|
lastPage: string;
|
||||||
chapterRight: string;
|
turnChapterRight: string;
|
||||||
chapterLeft: string;
|
turnChapterLeft: string;
|
||||||
exitReader: string;
|
exitReader: string;
|
||||||
toggleReadingDirection: string;
|
toggleReadingDirection: string;
|
||||||
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 = {
|
||||||
pageRight: "ArrowRight",
|
turnPageRight: "ArrowRight",
|
||||||
pageLeft: "ArrowLeft",
|
turnPageLeft: "ArrowLeft",
|
||||||
firstPage: "ctrl+ArrowLeft",
|
firstPage: "ctrl+ArrowLeft",
|
||||||
lastPage: "ctrl+ArrowRight",
|
lastPage: "ctrl+ArrowRight",
|
||||||
chapterRight: "]",
|
turnChapterRight: "]",
|
||||||
chapterLeft: "[",
|
turnChapterLeft: "[",
|
||||||
exitReader: "Backspace",
|
exitReader: "Backspace",
|
||||||
toggleReadingDirection: "d",
|
toggleReadingDirection: "d",
|
||||||
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> = {
|
||||||
pageRight: "Turn page right",
|
turnPageRight: "Turn page right (→)",
|
||||||
pageLeft: "Turn page left",
|
turnPageLeft: "Turn page left (←)",
|
||||||
firstPage: "Jump to first page",
|
firstPage: "Jump to first page",
|
||||||
lastPage: "Jump to last page",
|
lastPage: "Jump to last page",
|
||||||
chapterRight: "Next chapter",
|
turnChapterRight: "Turn chapter right (→)",
|
||||||
chapterLeft: "Previous chapter",
|
turnChapterLeft: "Turn chapter left (←)",
|
||||||
exitReader: "Exit reader",
|
exitReader: "Exit reader",
|
||||||
toggleReadingDirection: "Toggle reading direction",
|
toggleReadingDirection: "Toggle reading direction",
|
||||||
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 {
|
||||||
|
|||||||
@@ -127,6 +127,17 @@ export const UPDATE_MANGA = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGAS = `
|
||||||
|
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
|
||||||
|
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
|
||||||
|
mangas {
|
||||||
|
id
|
||||||
|
inLibrary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const MARK_CHAPTER_READ = `
|
export const MARK_CHAPTER_READ = `
|
||||||
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||||
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||||
@@ -187,6 +198,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 +452,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 +553,7 @@ export const INSTALL_EXTERNAL_EXTENSION = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const GET_SETTINGS = `
|
export const GET_SETTINGS = `
|
||||||
@@ -454,4 +572,430 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_LIBRARY = `
|
||||||
|
mutation UpdateLibrary {
|
||||||
|
updateLibrary(input: {}) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo {
|
||||||
|
isRunning
|
||||||
|
finishedJobs
|
||||||
|
totalJobs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Backup ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CREATE_BACKUP = `
|
||||||
|
mutation CreateBackup {
|
||||||
|
createBackup(input: {}) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RESTORE_BACKUP = `
|
||||||
|
mutation RestoreBackup($backup: Upload!) {
|
||||||
|
restoreBackup(input: { backup: $backup }) {
|
||||||
|
id
|
||||||
|
status {
|
||||||
|
mangaProgress
|
||||||
|
state
|
||||||
|
totalManga
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_RESTORE_STATUS = `
|
||||||
|
query GetRestoreStatus($id: String!) {
|
||||||
|
restoreStatus(id: $id) {
|
||||||
|
mangaProgress
|
||||||
|
state
|
||||||
|
totalManga
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VALIDATE_BACKUP = `
|
||||||
|
query ValidateBackup($backup: Upload!) {
|
||||||
|
validateBackup(input: { backup: $backup }) {
|
||||||
|
missingSources {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
missingTrackers {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LIBRARY_UPDATE_STATUS = `
|
||||||
|
query LibraryUpdateStatus {
|
||||||
|
libraryUpdateStatus {
|
||||||
|
jobsInfo {
|
||||||
|
isRunning
|
||||||
|
finishedJobs
|
||||||
|
totalJobs
|
||||||
|
skippedMangasCount
|
||||||
|
}
|
||||||
|
mangaUpdates {
|
||||||
|
status
|
||||||
|
manga {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
thumbnailUrl
|
||||||
|
unreadCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -86,4 +99,50 @@ 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;
|
||||||
@@ -32,6 +154,14 @@ export interface ReadingStats {
|
|||||||
lastStreakDate: string;
|
lastStreakDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LibraryUpdateEntry {
|
||||||
|
mangaId: number;
|
||||||
|
mangaTitle: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
newChapters: number;
|
||||||
|
checkedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
const AVG_MIN_PER_CHAPTER = 5;
|
const AVG_MIN_PER_CHAPTER = 5;
|
||||||
|
|
||||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||||
@@ -59,19 +189,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 +227,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 +242,54 @@ 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;
|
||||||
|
savedIsDefaultCategory: boolean;
|
||||||
|
nsfwFilteredTags: string[];
|
||||||
|
nsfwAllowedSourceIds: string[];
|
||||||
|
nsfwBlockedSourceIds: string[];
|
||||||
|
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||||
|
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||||
|
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
||||||
|
maxPageWidth?: number;
|
||||||
|
uiScale?: number;
|
||||||
|
extraScanDirs: string[];
|
||||||
|
serverDownloadsPath: string;
|
||||||
|
serverLocalSourcePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +299,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 +314,53 @@ 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,
|
||||||
|
savedIsDefaultCategory: false,
|
||||||
|
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 +401,79 @@ 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 });
|
||||||
|
searchCache: Map<string, any> = $state(new Map());
|
||||||
|
searchLibraryIds: Set<number> = $state(new Set());
|
||||||
|
searchSrcOffset: number = $state(0);
|
||||||
|
readerSessionId: number = $state(0);
|
||||||
|
libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []);
|
||||||
|
lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0);
|
||||||
|
acknowledgedUpdates: Set<number> = $state(new Set(saved?.acknowledgedUpdateIds ?? []));
|
||||||
|
|
||||||
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,
|
||||||
|
libraryUpdates: this.libraryUpdates,
|
||||||
|
lastLibraryRefresh: this.lastLibraryRefresh,
|
||||||
|
acknowledgedUpdateIds: [...this.acknowledgedUpdates],
|
||||||
|
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 +483,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 +608,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 +620,151 @@ 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,
|
||||||
|
mangaStatus?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!chaps.length) return;
|
||||||
|
// Never auto-complete an ongoing series — user must set Completed manually.
|
||||||
|
if (mangaStatus === "ONGOING") 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) {
|
clearSearchCache() {
|
||||||
this.settings = {
|
this.searchCache = new Map();
|
||||||
...this.settings,
|
this.searchLibraryIds = new Set();
|
||||||
folders: this.settings.folders.map(f =>
|
this.searchSrcOffset++;
|
||||||
f.id === folderId && !f.mangaIds.includes(mangaId)
|
|
||||||
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
|
|
||||||
: f
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeMangaFromFolder(folderId: string, mangaId: number) {
|
setLibraryUpdates(entries: LibraryUpdateEntry[]) {
|
||||||
this.settings = {
|
this.libraryUpdates = entries;
|
||||||
...this.settings,
|
this.lastLibraryRefresh = Date.now();
|
||||||
folders: this.settings.folders.map(f =>
|
|
||||||
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) } : f
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getMangaFolders(mangaId: number): Folder[] {
|
clearLibraryUpdates() {
|
||||||
return this.settings.folders.filter(f => f.mangaIds.includes(mangaId));
|
this.libraryUpdates = [];
|
||||||
|
this.lastLibraryRefresh = 0;
|
||||||
|
this.acknowledgedUpdates = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
acknowledgeUpdate(mangaId: number) {
|
||||||
|
if (this.acknowledgedUpdates.has(mangaId)) return;
|
||||||
|
this.acknowledgedUpdates = new Set([...this.acknowledgedUpdates, mangaId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpReaderSession() {
|
||||||
|
this.readerSessionId++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 clearSearchCache() { store.clearSearchCache(); }
|
||||||
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
export function setLibraryUpdates(entries: LibraryUpdateEntry[]) { store.setLibraryUpdates(entries); }
|
||||||
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
export function clearLibraryUpdates() { store.clearLibraryUpdates(); }
|
||||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
export function acknowledgeUpdate(mangaId: number) { store.acknowledgeUpdate(mangaId); }
|
||||||
export function resetKeybinds() { store.resetKeybinds(); }
|
export function bumpReaderSession() { store.bumpReaderSession(); }
|
||||||
export function addFolder(name: string) { return store.addFolder(name); }
|
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
|
||||||
export function removeFolder(id: string) { store.removeFolder(id); }
|
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
|
||||||
export function renameFolder(id: string, name: string) { store.renameFolder(id, name); }
|
export function clearBookmarks() { store.clearBookmarks(); }
|
||||||
export function toggleFolderTab(id: string) { store.toggleFolderTab(id); }
|
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
|
||||||
export function assignMangaToFolder(folderId: string, mangaId: number) { store.assignMangaToFolder(folderId, mangaId); }
|
export function addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { return store.addMarker(entry); }
|
||||||
export function removeMangaFromFolder(folderId: string, mangaId: number) { store.removeMangaFromFolder(folderId, mangaId); }
|
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { store.updateMarker(id, patch); }
|
||||||
export function getMangaFolders(mangaId: number) { return store.getMangaFolders(mangaId); }
|
export function removeMarker(id: string) { store.removeMarker(id); }
|
||||||
|
export function getMarkersForPage(chapterId: number, page: number) { return store.getMarkersForPage(chapterId, page); }
|
||||||
|
export function getMarkersForChapter(chapterId: number) { return store.getMarkersForChapter(chapterId); }
|
||||||
|
export function getMarkersForManga(mangaId: number) { return store.getMarkersForManga(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,
|
||||||
|
mangaStatus?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
|
||||||
|
}
|
||||||
|
|||||||