Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06cb70048b | |||
| d3e62a7a08 | |||
| b6ef2b1b3c | |||
| c13a4eb77a | |||
| bd972eccf3 | |||
| 9610c0294d | |||
| 406819ccca | |||
| 272e026210 | |||
| 57bf9d5fb1 | |||
| 7df7191799 | |||
| e6b542cd6b | |||
| 4903b066b1 | |||
| 96bac1ad2b | |||
| 94b92d000f | |||
| 43630ef72d | |||
| 161b1f9f52 | |||
| 816b384d64 | |||
| b772b94c6c | |||
| deb8a5ee02 | |||
| 821e13fc44 | |||
| 937054d674 | |||
| 4532b37201 | |||
| 73b73e85d7 | |||
| 697116b630 | |||
| 0e87c51801 | |||
| bf38e00cf3 | |||
| eb7360ee05 | |||
| c9eba3da86 | |||
| fc68d3ac7e | |||
| 1fa1c3a2e0 | |||
| 8c38330143 | |||
| 272d7673ce | |||
| 3d074a1fb1 | |||
| be15cb6ad8 | |||
| 3aee69939b | |||
| 0557f3f2d6 | |||
| 817af0d10a | |||
| 70afb08f83 | |||
| f751f34c68 | |||
| 8c9d3fc783 | |||
| 0f0cd87e6d | |||
| f5a1b13e43 | |||
| 4fca379715 | |||
| ac5e3ae53b | |||
| 6d39d5574a | |||
| 5e8f0d2f52 | |||
| 87e2009d4e | |||
| 2f5103c48c | |||
| 9d9c1b61e7 | |||
| a1a0f360d7 | |||
| 9a0afed2b0 | |||
| 28e9e3bcf8 | |||
| ac04c39ead |
@@ -1,66 +0,0 @@
|
||||
name: Build AppImage
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag (e.g. 0.1.0)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# ubuntu-20.04 ships webkit2gtk 2.44 by default, avoiding the
|
||||
# EGL_BAD_PARAMETER crash present in 2.46+
|
||||
# https://github.com/gitbutlerapp/gitbutler/issues/5282
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libsoup-3.0-dev \
|
||||
patchelf \
|
||||
file
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build AppImage
|
||||
run: pnpm tauri build --bundles appimage
|
||||
env:
|
||||
NO_STRIP: "true"
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Moku-${{ github.event.inputs.version || github.sha }}-amd64.AppImage
|
||||
path: src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,248 @@
|
||||
name: Build macOS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.3.0)"
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Build frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
tauri:
|
||||
name: Tauri (macOS)
|
||||
needs: frontend
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download Suwayomi binaries
|
||||
run: |
|
||||
download_suwayomi() {
|
||||
local asset="$1" sha="$2" outdir="$3"
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
|
||||
-o "${outdir}.tar.gz"
|
||||
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||
mkdir -p "${outdir}"
|
||||
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
||||
}
|
||||
|
||||
download_suwayomi \
|
||||
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
|
||||
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
|
||||
"suwayomi-arm64"
|
||||
|
||||
download_suwayomi \
|
||||
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
|
||||
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
|
||||
"suwayomi-x64"
|
||||
|
||||
- name: Stage Suwayomi sidecars
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
|
||||
find_launcher() {
|
||||
local dir="$1"
|
||||
# v2.1.1867 macOS tarball ships "Suwayomi Launcher.command" (space, .command)
|
||||
find "$dir" -maxdepth 1 -type f -name "*.command" | head -1
|
||||
}
|
||||
|
||||
ARM_LAUNCHER=$(find_launcher suwayomi-arm64)
|
||||
X64_LAUNCHER=$(find_launcher suwayomi-x64)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
- name: Swap bundle for aarch64
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-arm64 src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Build Tauri app (aarch64)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
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 || '-' }}
|
||||
with:
|
||||
args: --target aarch64-apple-darwin
|
||||
|
||||
- name: Swap bundle for x86_64
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-x64 src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Build Tauri app (x86_64)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
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 || '-' }}
|
||||
with:
|
||||
args: --target x86_64-apple-darwin
|
||||
|
||||
- name: Upload arm64 .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-aarch64
|
||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload x64 .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-x86_64
|
||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
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
|
||||
@@ -0,0 +1,147 @@
|
||||
name: Build Windows
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.4.0)"
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Build frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-windows
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
tauri:
|
||||
name: Tauri (Windows x64)
|
||||
needs: frontend
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-windows
|
||||
path: dist/
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download Suwayomi (Windows x64)
|
||||
shell: bash
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
||||
-o suwayomi-windows.zip
|
||||
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||
|
||||
- name: Extract Suwayomi bundle
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p suwayomi-extracted
|
||||
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
|
||||
cp -r "$INNER"/. suwayomi-extracted/
|
||||
else
|
||||
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||
fi
|
||||
echo "Extracted bundle contents (top-level):"
|
||||
ls -la suwayomi-extracted/
|
||||
|
||||
- name: Stage Suwayomi bundle
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||
if [ -z "$JAVA" ]; then
|
||||
echo "ERROR: jre/bin/java.exe not found. Bundle contents:"
|
||||
find suwayomi-extracted -type f | head -50
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$JAR" ]; then
|
||||
echo "ERROR: Suwayomi-Server.jar not found. Bundle contents:"
|
||||
find suwayomi-extracted -type f | head -50
|
||||
exit 1
|
||||
fi
|
||||
echo "Found java: $JAVA"
|
||||
echo "Found jar: $JAR"
|
||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Validate staging
|
||||
shell: bash
|
||||
run: |
|
||||
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
|
||||
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
|
||||
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
|
||||
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
|
||||
echo "Staging OK"
|
||||
|
||||
- name: Patch tauri.conf.json for CI
|
||||
shell: bash
|
||||
run: |
|
||||
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)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
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
|
||||
@@ -38,4 +38,4 @@ src-tauri/gen/
|
||||
build-dir/
|
||||
repo/
|
||||
*.flatpak
|
||||
.flatpak-builder/
|
||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=moku
|
||||
pkgver=0.2.0
|
||||
pkgver=0.4.0
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
@@ -22,7 +22,7 @@ source=(
|
||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
||||
)
|
||||
sha256sums=('dfd110ae4f11711ce979020ae65b08ab2d0bd51ecc1ba877ba1780ba037357a4'
|
||||
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||
|
||||
@@ -33,14 +33,8 @@ prepare() {
|
||||
|
||||
build() {
|
||||
cd "Moku-$pkgver"
|
||||
|
||||
# Build frontend
|
||||
pnpm build
|
||||
|
||||
# Repack dist for Tauri
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
|
||||
# Build Tauri binary
|
||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||
--release \
|
||||
--manifest-path src-tauri/Cargo.toml
|
||||
@@ -49,19 +43,15 @@ build() {
|
||||
package() {
|
||||
cd "Moku-$pkgver"
|
||||
|
||||
# Moku binary
|
||||
install -Dm755 src-tauri/target/release/moku \
|
||||
"$pkgdir/usr/bin/moku"
|
||||
|
||||
# Bundled JRE
|
||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
||||
|
||||
# Suwayomi server jar
|
||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||
|
||||
# tachidesk-server wrapper script
|
||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
||||
server.ip = "127.0.0.1"
|
||||
@@ -109,7 +99,6 @@ exec /usr/lib/moku/jre/bin/java \
|
||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||
EOF
|
||||
|
||||
# Desktop entry and icons
|
||||
install -Dm644 packaging/dev.moku.app.desktop \
|
||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
||||
install -Dm644 src-tauri/icons/32x32.png \
|
||||
|
||||
@@ -1,78 +1,11 @@
|
||||
<div align="center">
|
||||
<img src="src/assets/rounded-logo.png" width="96" />
|
||||
<img src="src/assets/moku-icon-rounded.svg" width="96" />
|
||||
<h1>Moku</h1>
|
||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Reader
|
||||
- **Single**, **double-page**, and **longstrip** reading modes
|
||||
- **Infinite longstrip** — when Auto mode is enabled, the next chapter's pages are appended directly into the scroll without any re-render or gap; the entire series flows as one seamless ribbon
|
||||
- Fit modes: fit width, fit height, fit screen, and 1:1 original
|
||||
- Per-series zoom control via Ctrl+scroll or a slider popover
|
||||
- RTL / LTR reading direction toggle
|
||||
- Configurable page gaps
|
||||
- Full keyboard navigation with rebindable keybinds
|
||||
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
|
||||
- Chapter-relative page counter that updates live as you scroll through the infinite strip
|
||||
- Auto-mark chapters as read when the last page is reached
|
||||
|
||||
### Library
|
||||
- Grid view of your entire manga collection with lazy-loaded cover art
|
||||
- Filter tabs: **Saved**, **Downloaded**, and **All**
|
||||
- Genre tag filter chips — multi-select to narrow by any combination of tags
|
||||
- In-line search
|
||||
- Context menu: open, add/remove from library
|
||||
|
||||
### Series Detail
|
||||
- Cover, author, artist, status badge, genres, and synopsis
|
||||
- Read progress bar with percentage
|
||||
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
|
||||
- Chapter list with scanlator, upload date, and in-progress page indicator
|
||||
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
|
||||
- Sort by newest or oldest first
|
||||
- Jump-to-chapter input
|
||||
- Bulk download menu: from current chapter, unread only, or all
|
||||
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
|
||||
- Collapsible source details panel with source ID, language, and source migration
|
||||
|
||||
### Search
|
||||
- Cross-source search running up to 3 concurrent requests
|
||||
- Language filter bar (preferred language default, per-language, or all)
|
||||
- Results grouped by source with skeleton loading states
|
||||
|
||||
### Sources & Extensions
|
||||
- Browse and search installed sources, grouped by extension with per-language expansion
|
||||
- Extension manager: install, update, remove, and install from external APK URL
|
||||
- Repo refresh with update count badge
|
||||
|
||||
### Downloads
|
||||
- Download queue with live progress
|
||||
|
||||
### History
|
||||
- Reading history grouped by day with relative timestamps
|
||||
- Per-entry thumbnail, chapter name, and last-read page
|
||||
- Full-text search across titles and chapter names
|
||||
- One-click clear
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
||||
@@ -114,20 +47,15 @@ pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Svelte](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
|
||||
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
|
||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
Todo:
|
||||
3. Explore Manga Upscaler & Other Image Processing
|
||||
4. Font Weird on Flatpak, Investigate and Fix
|
||||
5. Investigate "egl:failed to create dri2 screen"
|
||||
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
|
||||
|
||||
|
||||
Bugs:
|
||||
|
||||
-
|
||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
||||
- Add Back after Search & Clear on Search
|
||||
- Add as Package in Nix Flake & Check Later
|
||||
- GenreDrill & GenreFilter pages do not populate completely.
|
||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
||||
- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy
|
||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
||||
|
||||
|
||||
- Fix Mangafire Main Dispatcher Issue
|
||||
|
||||
|
||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
||||
- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
|
||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
||||
|
||||
- Clean up Migrate Model to be more initutive
|
||||
|
||||
- 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
|
||||
- Major revision to disable entire manga-subsection and use as
|
||||
solely as a reader/document launcher.
|
||||
- Multiple Tag Filters + Mor Tags, Types, Etc
|
||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
||||
- Properly Kill Tachidesk-Server
|
||||
- Migration Features
|
||||
- Multi-Page Long Screenshot
|
||||
-
|
||||
- Add Consumet Api (Anime & Light Novel Support)
|
||||
|
||||
|
||||
Big Revisions:
|
||||
@@ -47,14 +44,9 @@ Big Revisions:
|
||||
|
||||
|
||||
Testing:
|
||||
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 Download Cards (Series Detail Download UI) & Fix Download Range Expand
|
||||
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
||||
20. Expand History (Total Time Read, etc)
|
||||
12. Delete all Downloads should also cancel all download queues
|
||||
13. Cancel Download along with Queue & Download Timeout Feature
|
||||
|
||||
- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
|
||||
- Fix the Mark as Read (Glitched)
|
||||
|
||||
|
||||
Completed:
|
||||
@@ -74,6 +66,29 @@ Completed:
|
||||
18. Disable NSFW Extensions option in settings
|
||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
||||
- Remove Series Detail Mark Read & Unread
|
||||
20. Expand History (Total Time Read, etc)
|
||||
12. Delete all Downloads should also cancel all download queues
|
||||
13. Cancel Download along with Queue & Download Timeout Feature
|
||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
||||
- Extensions Page no Longer Loading efficiently
|
||||
- Map out MangaPreview tags to GenreDrill
|
||||
- GenreDrill & GenreFilter pages do not populate completely.
|
||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
||||
- Clean up Migrate Model to be more initutive
|
||||
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
||||
5. Lock reader on valid chapters to avoid bugs, etc.
|
||||
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
||||
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
||||
- Properly Kill Tachidesk-Server
|
||||
- Fix scaling on splash screen
|
||||
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
|
||||
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
|
||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-scripts/release.sh
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Usage:
|
||||
# ./build-scripts/release.sh 0.2.0 — full release (AUR + Flatpak)
|
||||
# ./build-scripts/release.sh 0.2.0 --aur — AUR bin package only
|
||||
# ./build-scripts/release.sh 0.2.0 --flatpak — Flatpak sources + bundle only
|
||||
#
|
||||
# Requires: nix, podman (for AUR .SRCINFO generation in Arch container)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colour helpers ─────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN} →${RESET} $*"; }
|
||||
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW} ⚠${RESET} $*"; }
|
||||
die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
||||
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
||||
|
||||
# ── Args ───────────────────────────────────────────────────────────────────────
|
||||
[[ $# -lt 1 ]] && die "Usage: $0 <version> [--aur|--flatpak]"
|
||||
|
||||
VERSION="$1"
|
||||
MODE="${2:-all}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
AUR_DIR="${REPO_ROOT}/../moku-bin"
|
||||
TARBALL="moku-${VERSION}-x86_64.tar.gz"
|
||||
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
|
||||
|
||||
# ── Sanity checks ──────────────────────────────────────────────────────────────
|
||||
section "Pre-flight"
|
||||
command -v nix &>/dev/null || die "nix not found"
|
||||
|
||||
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
||||
command -v podman &>/dev/null || die "podman not found — needed for Arch container (makepkg)"
|
||||
[[ -d "$AUR_DIR" ]] || die "AUR dir not found at $AUR_DIR\nClone it first:\n git clone ssh://aur@aur.archlinux.org/moku-bin.git ../moku-bin"
|
||||
[[ -f "${AUR_DIR}/PKGBUILD" ]] || die "PKGBUILD not found in $AUR_DIR"
|
||||
fi
|
||||
success "OK"
|
||||
|
||||
# ── Bump versions ──────────────────────────────────────────────────────────────
|
||||
section "Bumping version → ${VERSION}"
|
||||
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \
|
||||
"${REPO_ROOT}/src-tauri/tauri.conf.json"
|
||||
success "tauri.conf.json → ${VERSION}"
|
||||
|
||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"${VERSION}\"/" \
|
||||
"${REPO_ROOT}/src-tauri/Cargo.toml"
|
||||
success "Cargo.toml → ${VERSION}"
|
||||
|
||||
# ── Build frontend ─────────────────────────────────────────────────────────────
|
||||
section "Building frontend"
|
||||
cd "$REPO_ROOT"
|
||||
nix develop --command pnpm install --frozen-lockfile
|
||||
nix develop --command pnpm build
|
||||
success "Frontend built → dist/"
|
||||
|
||||
# ── Build Rust binary ──────────────────────────────────────────────────────────
|
||||
section "Building Rust binary"
|
||||
nix develop --command cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
BINARY="${REPO_ROOT}/src-tauri/target/release/moku"
|
||||
[[ -f "$BINARY" ]] || die "Binary not found: $BINARY"
|
||||
success "Binary → $BINARY"
|
||||
|
||||
# ── Flatpak ────────────────────────────────────────────────────────────────────
|
||||
if [[ "$MODE" == "all" || "$MODE" == "--flatpak" ]]; then
|
||||
section "Regenerating cargo-sources.json"
|
||||
cd "$REPO_ROOT"
|
||||
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"
|
||||
success "cargo-sources.json updated"
|
||||
|
||||
section "Rebuilding frontend-dist.tar.gz"
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
||||
success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}"
|
||||
|
||||
# Patch the sha256 in dev.moku.app.yml automatically via a temp script
|
||||
PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py)
|
||||
cat > "$PATCH_SCRIPT" << PYEOF
|
||||
import re, sys
|
||||
|
||||
path = "${FLATPAK_MANIFEST}"
|
||||
new_sha = "${FRONTEND_SHA}"
|
||||
text = open(path).read()
|
||||
|
||||
pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+'
|
||||
replacement = r'\g<1>' + new_sha
|
||||
updated, n = re.subn(pattern, replacement, text)
|
||||
if n == 0:
|
||||
sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml")
|
||||
open(path, 'w').write(updated)
|
||||
PYEOF
|
||||
nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'"
|
||||
rm -f "$PATCH_SCRIPT"
|
||||
success "dev.moku.app.yml sha256 updated"
|
||||
|
||||
section "Building Flatpak bundle"
|
||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
||||
|
||||
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \
|
||||
flatpak-builder \
|
||||
--repo="${REPO_ROOT}/repo" \
|
||||
--force-clean \
|
||||
"${REPO_ROOT}/build-dir" \
|
||||
"$FLATPAK_MANIFEST"
|
||||
|
||||
flatpak build-bundle \
|
||||
"${REPO_ROOT}/repo" \
|
||||
"${REPO_ROOT}/moku.flatpak" \
|
||||
dev.moku.app
|
||||
|
||||
# Clean up intermediate build artefacts — keep only moku.flatpak
|
||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
||||
success "moku.flatpak created"
|
||||
fi
|
||||
|
||||
# ── AUR tarball + PKGBUILD ─────────────────────────────────────────────────────
|
||||
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
||||
section "Assembling release tarball"
|
||||
cd "$REPO_ROOT"
|
||||
STAGE="release-${VERSION}"
|
||||
rm -rf "$STAGE"
|
||||
|
||||
install -Dm755 "$BINARY" "${STAGE}/usr/bin/moku"
|
||||
install -Dm644 packaging/dev.moku.app.desktop "${STAGE}/usr/share/applications/dev.moku.app.desktop"
|
||||
install -Dm644 src-tauri/icons/32x32.png "${STAGE}/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
||||
install -Dm644 src-tauri/icons/128x128.png "${STAGE}/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" "${STAGE}/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
||||
install -Dm644 packaging/dev.moku.app.metainfo.xml "${STAGE}/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
||||
|
||||
tar -czf "$TARBALL" "$STAGE/"
|
||||
AUR_SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
|
||||
rm -rf "$STAGE"
|
||||
success "Tarball: ${TARBALL} sha256: ${AUR_SHA}"
|
||||
|
||||
section "Patching PKGBUILD"
|
||||
PKGBUILD="${AUR_DIR}/PKGBUILD"
|
||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
||||
sed -i "s/sha256sums=('[^']*')/sha256sums=('${AUR_SHA}')/" "$PKGBUILD"
|
||||
success "PKGBUILD patched"
|
||||
|
||||
# Tarball is only needed for the GitHub upload — remind user then it can go
|
||||
info "Tarball kept at ${REPO_ROOT}/${TARBALL} — upload it to GitHub, then it can be deleted"
|
||||
|
||||
section "Generating .SRCINFO (Arch container)"
|
||||
# Mount only the AUR dir into a throwaway Arch container and run makepkg
|
||||
podman run --rm \
|
||||
--volume "${AUR_DIR}:/aur:z" \
|
||||
--workdir /aur \
|
||||
archlinux:latest \
|
||||
bash -c "
|
||||
pacman -Sy --noconfirm pacman >/dev/null 2>&1
|
||||
source PKGBUILD
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
"
|
||||
success ".SRCINFO generated"
|
||||
|
||||
section "Next steps"
|
||||
echo ""
|
||||
echo -e " ${BOLD}1. Upload tarball to GitHub:${RESET}"
|
||||
echo -e " ${CYAN}gh release create v${VERSION} '${REPO_ROOT}/${TARBALL}' --title 'v${VERSION}' --generate-notes${RESET}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}2. Push AUR:${RESET}"
|
||||
echo -e " ${CYAN}cd ${AUR_DIR}${RESET}"
|
||||
echo -e " ${CYAN}git add PKGBUILD .SRCINFO${RESET}"
|
||||
echo -e " ${CYAN}git commit -m 'Update to ${VERSION}'${RESET}"
|
||||
echo -e " ${CYAN}git push origin master${RESET}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}3. Clean up:${RESET}"
|
||||
echo -e " ${CYAN}rm -f ${REPO_ROOT}/${TARBALL}${RESET}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
success "v${VERSION} ready"
|
||||
@@ -181,7 +181,7 @@ modules:
|
||||
path: .
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a
|
||||
sha256: c78a3f002f898011c4e70e1af781b37dac0fd995b5623170256d88339c90ca74
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1771438068,
|
||||
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=",
|
||||
"lastModified": 1773857772,
|
||||
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597",
|
||||
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -15,32 +15,16 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769996383,
|
||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -49,53 +33,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-appimage": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1757920913,
|
||||
"narHash": "sha256-jd0QwCVz4O1sHHkeaZILD/7D6oyalceEJ4EFnWCgm0k=",
|
||||
"owner": "ralismark",
|
||||
"repo": "nix-appimage",
|
||||
"rev": "7946addbc0d97e358a6d7aefe5e82310f0fe6b18",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ralismark",
|
||||
"repo": "nix-appimage",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771369470,
|
||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||
"lastModified": 1773821835,
|
||||
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -107,11 +51,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1769909678,
|
||||
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
||||
"lastModified": 1772328832,
|
||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -124,7 +68,6 @@
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-parts": "flake-parts",
|
||||
"nix-appimage": "nix-appimage",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
@@ -136,11 +79,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771556776,
|
||||
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
|
||||
"lastModified": 1773975983,
|
||||
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
|
||||
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -148,21 +91,6 @@
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -9,36 +9,27 @@
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nix-appimage = {
|
||||
url = "github:ralismark/nix-appimage";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ flake-parts, crane, rust-overlay, nix-appimage, ... }:
|
||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
|
||||
perSystem =
|
||||
{ system, pkgs, lib, ... }:
|
||||
perSystem = { system, lib, ... }:
|
||||
let
|
||||
pkgs' = import inputs.nixpkgs {
|
||||
version = "0.4.0";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs'.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rust-analyzer"
|
||||
];
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" "rust-analyzer" ];
|
||||
};
|
||||
|
||||
craneLib = (crane.mkLib pkgs').overrideToolchain rustToolchain;
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
|
||||
runtimeLibs = with pkgs; [
|
||||
webkitgtk_4_1
|
||||
@@ -65,31 +56,22 @@
|
||||
|| base == "package.json"
|
||||
|| base == "pnpm-lock.yaml"
|
||||
|| base == "tsconfig.json"
|
||||
|| base == "tsconfig.node.json"
|
||||
|| base == "vite.config.ts"
|
||||
|| base == "postcss.config.js"
|
||||
|| base == "postcss.config.cjs"
|
||||
|| base == "tailwind.config.js"
|
||||
|| base == "tailwind.config.ts";
|
||||
|| base == "vite.config.ts";
|
||||
};
|
||||
|
||||
frontend = pkgs.stdenv.mkDerivation {
|
||||
pname = "moku-frontend";
|
||||
version = "0.1.0";
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
nodejs_22
|
||||
pnpm
|
||||
pnpmConfigHook
|
||||
];
|
||||
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
pname = "moku-frontend";
|
||||
version = "0.1.0";
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
||||
hash = "sha256-FsZTHeBS9qQ9KYgiwDX1vam6uJXK8OjLe5U6Jfu33lc=";
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
@@ -111,10 +93,7 @@
|
||||
cargoLock = ./src-tauri/Cargo.lock;
|
||||
strictDeps = true;
|
||||
buildInputs = runtimeLibs;
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
wrapGAppsHook3
|
||||
];
|
||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
||||
preBuild = ''
|
||||
cp -r ${frontend} ../dist
|
||||
'';
|
||||
@@ -126,6 +105,36 @@
|
||||
inherit cargoArtifacts;
|
||||
meta.mainProgram = "moku";
|
||||
postInstall = ''
|
||||
mkdir -p "$out/share/applications"
|
||||
cat > "$out/share/applications/moku.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Moku
|
||||
Comment=Manga reader frontend for Suwayomi
|
||||
Exec=$out/bin/moku
|
||||
Icon=moku
|
||||
Terminal=false
|
||||
Categories=Graphics;Viewer;
|
||||
Keywords=manga;comic;reader;suwayomi;
|
||||
StartupWMClass=moku
|
||||
EOF
|
||||
|
||||
for size in 32x32 128x128 256x256 512x512; do
|
||||
src="icons/$size.png"
|
||||
[ -f "$src" ] && install -Dm644 "$src" \
|
||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||
done
|
||||
|
||||
for size in 128x128 256x256; do
|
||||
src="icons/''${size}@2x.png"
|
||||
[ -f "$src" ] && install -Dm644 "$src" \
|
||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||
done
|
||||
|
||||
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
||||
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||
|
||||
wrapProgram $out/bin/moku \
|
||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||
pkgs.gsettings-desktop-schemas
|
||||
@@ -134,16 +143,139 @@
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
||||
--set GDK_BACKEND wayland \
|
||||
--set WEBKIT_FORCE_SANDBOX 0
|
||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||
'';
|
||||
});
|
||||
|
||||
bumpScript = pkgs.writeShellApplication {
|
||||
name = "moku-bump";
|
||||
runtimeInputs = with pkgs; [ gnused coreutils git ];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/tauri.conf.json"
|
||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/Cargo.toml"
|
||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||
"$REPO/flake.nix"
|
||||
echo "Bumped to $VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
flatpakScript = pkgs.writeShellApplication {
|
||||
name = "moku-flatpak";
|
||||
runtimeInputs = with pkgs; [
|
||||
gnused coreutils git
|
||||
nodejs_22 pnpm
|
||||
appstream flatpak-builder flatpak
|
||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||
];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
MANIFEST="$REPO/dev.moku.app.yml"
|
||||
|
||||
echo "── Bumping versions ──"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/tauri.conf.json"
|
||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/Cargo.toml"
|
||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||
"$REPO/flake.nix"
|
||||
echo "Done"
|
||||
|
||||
echo "── Building frontend ──"
|
||||
cd "$REPO"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
echo "Done"
|
||||
|
||||
echo "── Repacking frontend-dist.tar.gz ──"
|
||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO/dist" .
|
||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||
echo "sha256: $FRONTEND_SHA"
|
||||
|
||||
echo "── Patching manifest sha256 ──"
|
||||
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
||||
import re, sys
|
||||
path, sha = sys.argv[1], sys.argv[2]
|
||||
text = open(path).read()
|
||||
updated, n = re.subn(
|
||||
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||
r'\g<1>' + sha, text)
|
||||
if n == 0:
|
||||
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||
open(path, 'w').write(updated)
|
||||
PYEOF
|
||||
echo "Done"
|
||||
|
||||
echo "── Regenerating cargo-sources.json ──"
|
||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||
"$REPO/src-tauri/Cargo.lock" \
|
||||
-o "$REPO/packaging/cargo-sources.json"
|
||||
echo "Done"
|
||||
|
||||
echo "── Building flatpak ──"
|
||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||
flatpak-builder \
|
||||
--repo="$REPO/repo" \
|
||||
--force-clean \
|
||||
"$REPO/build-dir" \
|
||||
"$MANIFEST"
|
||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" dev.moku.app
|
||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||
echo "moku.flatpak created"
|
||||
|
||||
echo ""
|
||||
echo "Done — v$VERSION"
|
||||
echo " -> $REPO/moku.flatpak"
|
||||
echo ""
|
||||
echo "After pushing the tag, run:"
|
||||
echo " nix run .#pkgbuild-bump -- $VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
pkgbuildBumpScript = pkgs.writeShellApplication {
|
||||
name = "moku-pkgbuild-bump";
|
||||
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
PKGBUILD="$REPO/PKGBUILD"
|
||||
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
||||
|
||||
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||
echo "Fetching tarball sha256..."
|
||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||
|
||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
||||
|
||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
|
||||
|
||||
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
|
||||
'';
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
apps = {
|
||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
||||
};
|
||||
|
||||
packages = {
|
||||
inherit moku frontend;
|
||||
default = moku;
|
||||
appimage = nix-appimage.bundlers."${system}".default moku;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
@@ -158,26 +290,16 @@
|
||||
xdg-utils
|
||||
];
|
||||
shellHook = ''
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
export NO_STRIP=true
|
||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
||||
|
||||
if [ ! -e /usr/bin/xdg-open ]; then
|
||||
sudo ln -sf ${pkgs.xdg-utils}/bin/xdg-open /usr/bin/xdg-open
|
||||
fi
|
||||
|
||||
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage"
|
||||
LINUXDEPLOY_REAL="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage.real"
|
||||
if [ -f "$LINUXDEPLOY" ] && [ ! -f "$LINUXDEPLOY_REAL" ]; then
|
||||
mv "$LINUXDEPLOY" "$LINUXDEPLOY_REAL"
|
||||
printf '#!/bin/sh\nexec ${pkgs.appimage-run}/bin/appimage-run "%s" "$@"\n' "$LINUXDEPLOY_REAL" > "$LINUXDEPLOY"
|
||||
chmod +x "$LINUXDEPLOY"
|
||||
echo "linuxdeploy wrapped with appimage-run"
|
||||
fi
|
||||
|
||||
echo "Moku dev shell"
|
||||
echo " pnpm install && pnpm tauri:dev"
|
||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||
echo ""
|
||||
echo "Release:"
|
||||
echo " nix run .#bump -- <ver> bump versions only"
|
||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Moku</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,41 +1,26 @@
|
||||
{
|
||||
"name": "moku",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"tauri:build": "tauri build"
|
||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"zustand": "^5.0.0"
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^3.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,9 @@
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="0.1.0" date="2025-01-01">
|
||||
<release version="0.4.0" date="2025-03-22">
|
||||
<description>
|
||||
<p>Initial release.</p>
|
||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
|
||||
@@ -285,12 +285,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
@@ -396,6 +390,25 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
@@ -645,6 +658,12 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -1797,12 +1816,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moku"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"nix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-shell",
|
||||
@@ -1866,24 +1885,21 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -2624,6 +2640,26 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -3242,6 +3278,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"rayon",
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3289,7 +3339,7 @@ dependencies = [
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -3360,7 +3410,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3486,7 +3536,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3512,7 +3562,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"wry",
|
||||
]
|
||||
|
||||
@@ -4241,10 +4291,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4265,7 +4315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4315,6 +4365,16 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core 0.57.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -4337,14 +4397,26 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
@@ -4356,8 +4428,8 @@ version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
@@ -4374,6 +4446,17 @@ dependencies = [
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
@@ -4385,6 +4468,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@@ -4418,6 +4512,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4921,7 +5024,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "moku_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "moku"
|
||||
@@ -20,7 +20,7 @@ tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
walkdir = "2"
|
||||
nix = { version = "0.29", features = ["fs"] }
|
||||
sysinfo = "0.32"
|
||||
dirs = "5"
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Allow launching tachidesk-server",
|
||||
"description": "Default permissions for Moku",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
{
|
||||
"identifier": "shell:allow-spawn",
|
||||
"allow": [
|
||||
{
|
||||
"name": "tachidesk-server",
|
||||
"cmd": "tachidesk-server"
|
||||
}
|
||||
]
|
||||
}
|
||||
"shell:allow-kill",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-execute",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-is-minimized",
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-outer-size",
|
||||
"core:window:allow-inner-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-scale-factor"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 803 B After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,6 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use nix::sys::statvfs::statvfs;
|
||||
use std::io::Write;
|
||||
use sysinfo::Disks;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, WindowEvent};
|
||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||
@@ -16,17 +17,31 @@ pub struct StorageInfo {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "kind", content = "message")]
|
||||
pub enum SpawnError {
|
||||
NotConfigured(String),
|
||||
SpawnFailed(String),
|
||||
}
|
||||
|
||||
/// Strip the \\?\ extended-length path prefix that Windows adds to long paths.
|
||||
/// Java and many other tools do not accept this prefix and will fail silently.
|
||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
||||
let s = path.to_string_lossy();
|
||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(stripped)
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||
if !downloads_path.trim().is_empty() {
|
||||
return PathBuf::from(downloads_path);
|
||||
}
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/"))
|
||||
.join(".local/share")
|
||||
});
|
||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
||||
base.join("Tachidesk/downloads")
|
||||
}
|
||||
|
||||
@@ -46,68 +61,350 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||
0
|
||||
};
|
||||
|
||||
let stat_path = if path.exists() { path.clone() } else {
|
||||
let stat_path = if path.exists() {
|
||||
path.clone()
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
};
|
||||
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
||||
|
||||
let frsize = vfs.fragment_size() as u64;
|
||||
let total_bytes = vfs.blocks() * frsize;
|
||||
let free_bytes = vfs.blocks_available() * frsize;
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let disk = disks
|
||||
.iter()
|
||||
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||
|
||||
Ok(StorageInfo {
|
||||
manga_bytes,
|
||||
total_bytes,
|
||||
free_bytes,
|
||||
total_bytes: disk.total_space(),
|
||||
free_bytes: disk.available_space(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the true OS-level scale factor for the main window.
|
||||
/// This reads directly from the underlying winit window handle, bypassing
|
||||
/// whatever value WebKitGTK happens to report to JS via window.devicePixelRatio.
|
||||
/// This is the only reliable way to get the correct DPR in all launch
|
||||
/// environments — tauri dev, nix run, flatpak, etc.
|
||||
#[tauri::command]
|
||||
fn get_scale_factor(window: tauri::Window) -> f64 {
|
||||
window.scale_factor().unwrap_or(1.0)
|
||||
fn get_platform_ui_scale() -> f64 {
|
||||
#[cfg(target_os = "windows")]
|
||||
return 1.0;
|
||||
#[cfg(target_os = "macos")]
|
||||
return 1.0;
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
return 1.5;
|
||||
}
|
||||
|
||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||
let state = app.state::<ServerState>();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(child) = guard.take() {
|
||||
if let Some(child) = state.0.lock().unwrap().take() {
|
||||
let _ = child.kill();
|
||||
println!("Killed tracked server child.");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/FI", "IMAGENAME eq java*"])
|
||||
.status();
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.arg("-f")
|
||||
.arg("tachidesk")
|
||||
.args(["-f", "tachidesk"])
|
||||
.status();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||
let state = app.state::<ServerState>();
|
||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.webUIInterface = "browser"
|
||||
server.webUIFlavor = "WebUI"
|
||||
server.webUIChannel = "stable"
|
||||
server.electronPath = ""
|
||||
server.debugLogsEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
server.autoDownloadNewChapters = false
|
||||
server.globalUpdateInterval = 12
|
||||
server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
"#;
|
||||
|
||||
fn seed_server_conf(data_dir: &PathBuf) {
|
||||
let conf_path = data_dir.join("server.conf");
|
||||
|
||||
if !conf_path.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
||||
eprintln!("Could not write server.conf: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
||||
|
||||
let patched = patch_conf_key(
|
||||
patch_conf_key(
|
||||
patch_conf_key(contents, "server.webUIEnabled", "false"),
|
||||
"server.initialOpenInBrowserEnabled", "false",
|
||||
),
|
||||
"server.systemTrayEnabled", "false",
|
||||
);
|
||||
|
||||
let _ = std::fs::write(&conf_path, patched);
|
||||
}
|
||||
|
||||
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
||||
let replacement = format!("{key} = {value}");
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
|
||||
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
||||
let mut out = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
out.push('\n');
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut out = text;
|
||||
if !out.ends_with('\n') { out.push('\n'); }
|
||||
out.push_str(&replacement);
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
fn suwayomi_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let guard = state.0.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
println!("Server already running, skipping spawn.");
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
||||
.join("moku\\tachidesk")
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||
.join("dev.moku.app/tachidesk")
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
||||
base.join("moku/tachidesk")
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerInvocation {
|
||||
bin: String,
|
||||
args: Vec<String>,
|
||||
working_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let java = bundle_dir.join("jre").join("bin").join("java.exe");
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
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] exists: {}", java.exists()));
|
||||
|
||||
if java.exists() { Some(java) } else { None }
|
||||
}
|
||||
|
||||
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
||||
eprintln!("{}", msg);
|
||||
if let Some(f) = log {
|
||||
let _ = writeln!(f, "{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_server_binary(
|
||||
binary: &str,
|
||||
app: &tauri::AppHandle,
|
||||
log: &mut Option<std::fs::File>,
|
||||
) -> Result<ServerInvocation, SpawnError> {
|
||||
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
||||
|
||||
if !binary.trim().is_empty() {
|
||||
do_log(log, "[resolve] using user-supplied binary path");
|
||||
return Ok(ServerInvocation {
|
||||
bin: binary.to_string(),
|
||||
args: vec![],
|
||||
working_dir: None,
|
||||
});
|
||||
}
|
||||
|
||||
let resource_dir = match app.path().resource_dir() {
|
||||
Ok(p) => {
|
||||
let stripped = strip_unc(p);
|
||||
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
|
||||
stripped
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("resource_dir error: {e}");
|
||||
do_log(log, &format!("[resolve] ERROR: {}", msg));
|
||||
return Err(SpawnError::SpawnFailed(msg));
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||
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.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) {
|
||||
Some(java) => {
|
||||
do_log(log, &format!("[resolve] java found: {:?}", java));
|
||||
if jar.exists() {
|
||||
do_log(log, "[resolve] both java and jar found — using bundled JRE");
|
||||
return Ok(ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
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] java NOT found in bundle — skipping bundled path");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let candidates = [
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
"suwayomi-server-x86_64-apple-darwin",
|
||||
"suwayomi-server",
|
||||
];
|
||||
for name in &candidates {
|
||||
let p = resource_dir.join(name);
|
||||
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
do_log(log, &format!("[resolve] using macOS candidate: {:?}", p));
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do_log(log, "[resolve] trying PATH fallback");
|
||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||
let found = std::process::Command::new("which")
|
||||
.arg(name)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
do_log(log, &format!("[resolve] PATH check {:?}: found={}", name, found));
|
||||
|
||||
if found {
|
||||
do_log(log, &format!("[resolve] using PATH binary: {}", name));
|
||||
return Ok(ServerInvocation {
|
||||
bin: name.to_string(),
|
||||
args: vec![],
|
||||
working_dir: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
do_log(log, "[resolve] FAILED — no binary found anywhere");
|
||||
Err(SpawnError::NotConfigured(
|
||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
||||
{
|
||||
let state = app.state::<ServerState>();
|
||||
if state.0.lock().unwrap().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let shell = app.shell();
|
||||
match shell.command(&binary).spawn() {
|
||||
let data_dir = suwayomi_data_dir();
|
||||
|
||||
let log_path = data_dir.join("moku-spawn.log");
|
||||
let _ = std::fs::create_dir_all(&data_dir);
|
||||
let mut log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.ok();
|
||||
|
||||
do_log(&mut log, "");
|
||||
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);
|
||||
do_log(&mut log, "[spawn_server] server.conf seeded");
|
||||
|
||||
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e));
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let bin_display = invocation.bin.clone();
|
||||
let rootdir_flag = format!(
|
||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||
data_dir.to_string_lossy()
|
||||
);
|
||||
|
||||
invocation.args.insert(0, rootdir_flag);
|
||||
|
||||
let working_dir = invocation.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()
|
||||
.command(&invocation.bin)
|
||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||
.args(&invocation.args)
|
||||
.current_dir(&working_dir);
|
||||
|
||||
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok((_rx, child)) => {
|
||||
println!("Spawned server: {}", binary);
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
*guard = Some(child);
|
||||
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
|
||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to spawn {}: {}", binary, e);
|
||||
Err(e.to_string())
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +424,7 @@ pub fn run() {
|
||||
get_storage_info,
|
||||
spawn_server,
|
||||
kill_server,
|
||||
get_scale_factor,
|
||||
get_platform_ui_scale,
|
||||
])
|
||||
.setup(|_app| Ok(()))
|
||||
.on_window_event(|window, event| {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"identifier": "dev.moku.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -17,7 +17,8 @@
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false
|
||||
"decorations": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -26,14 +27,24 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["appimage"],
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"externalBin": [],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"installMode": "currentUser"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
|
||||
@@ -9,5 +9,8 @@
|
||||
"devtools": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"externalBin": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"bundle": {
|
||||
"resources": [
|
||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||
"binaries/suwayomi-bundle/jre/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { gql } from "./lib/client";
|
||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||
import { store, addToast, setActiveDownloads } from "./store/state.svelte";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||
import Layout from "./components/layout/Layout.svelte";
|
||||
import Reader from "./components/reader/Reader.svelte";
|
||||
import Settings from "./components/settings/Settings.svelte";
|
||||
import TitleBar from "./components/layout/TitleBar.svelte";
|
||||
import Toaster from "./components/layout/Toaster.svelte";
|
||||
import SplashScreen from "./components/layout/SplashScreen.svelte";
|
||||
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
||||
|
||||
const MAX_ATTEMPTS = 60;
|
||||
|
||||
let serverProbeOk = $state(!store.settings.autoStartServer);
|
||||
let appReady = $state(!store.settings.autoStartServer);
|
||||
let failed = $state(false);
|
||||
let notConfigured = $state(false);
|
||||
let idle = $state(false);
|
||||
let devSplash = $state(false);
|
||||
let platformScale = $state(1);
|
||||
|
||||
function applyZoom() {
|
||||
const normalized = store.settings.uiScale * platformScale;
|
||||
document.documentElement.style.zoom = `${normalized}%`;
|
||||
document.documentElement.style.setProperty("--ui-scale", String(normalized));
|
||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`);
|
||||
}
|
||||
|
||||
let prevQueue: DownloadQueueItem[] = [];
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pollInterval: ReturnType<typeof setInterval>;
|
||||
let unlistenDownload: (() => void) | undefined;
|
||||
|
||||
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
||||
for (const item of prev) {
|
||||
if (item.state !== "DOWNLOADING") continue;
|
||||
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
||||
const manga = item.chapter.manga;
|
||||
addToast({ kind: "success", title: "Chapter downloaded",
|
||||
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
||||
duration: 4000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyQueue(next: DownloadQueueItem[]) {
|
||||
detectCompletions(prevQueue, next);
|
||||
prevQueue = next;
|
||||
setActiveDownloads(next.map(item => ({
|
||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
||||
})));
|
||||
}
|
||||
|
||||
function resetIdle() {
|
||||
if (idle) return;
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||
if (ms === 0) return;
|
||||
idleTimer = setTimeout(() => idle = true, ms);
|
||||
}
|
||||
|
||||
const idleEvents = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
idleEvents.forEach(e => window.addEventListener(e, resetIdle, { passive: true }));
|
||||
resetIdle();
|
||||
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-runs whenever uiScale or platformScale changes.
|
||||
store.settings.uiScale; platformScale;
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
document.documentElement.setAttribute("data-theme", store.settings.theme ?? "dark");
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||
poll();
|
||||
pollInterval = setInterval(poll, 2000);
|
||||
return () => clearInterval(pollInterval);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||
(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);
|
||||
applyZoom();
|
||||
|
||||
if (store.settings.autoStartServer) {
|
||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||
if (err?.kind === "NotConfigured") {
|
||||
notConfigured = true;
|
||||
} else {
|
||||
console.warn("Could not start server:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!serverProbeOk) {
|
||||
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 }[];
|
||||
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
unlistenDownload?.();
|
||||
delete (window as any).__mokuShowSplash;
|
||||
};
|
||||
});
|
||||
|
||||
function handleRetry() { failed = false; notConfigured = false; serverProbeOk = false; }
|
||||
</script>
|
||||
|
||||
{#if devSplash}
|
||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||
{:else if !appReady}
|
||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
||||
showCards={store.settings.splashCards ?? true}
|
||||
onReady={() => appReady = true}
|
||||
onRetry={handleRetry} />
|
||||
{:else}
|
||||
<div class="root">
|
||||
{#if idle && !store.activeChapter}
|
||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => idle = false, 340)} />
|
||||
{/if}
|
||||
{#if !store.activeChapter}<TitleBar />{/if}
|
||||
<div class="content">
|
||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||
</div>
|
||||
{#if store.settingsOpen}<Settings />{/if}
|
||||
<MangaPreview />
|
||||
<Toaster />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
</style>
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { gql } from "./lib/client";
|
||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||
import "./styles/global.css";
|
||||
import { useStore } from "./store";
|
||||
import Layout from "./components/layout/Layout";
|
||||
import Reader from "./components/pages/Reader";
|
||||
import Settings from "./components/settings/Settings";
|
||||
import MangaPreview from "./components/explore/MangaPreview";
|
||||
import TitleBar from "./components/layout/TitleBar";
|
||||
import Toaster from "./components/layout/Toaster";
|
||||
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||
import s from "./App.module.css";
|
||||
|
||||
const MAX_ATTEMPTS = 30;
|
||||
|
||||
export default function App() {
|
||||
const activeChapter = useStore((s) => s.activeChapter);
|
||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
const addToast = useStore((s) => s.addToast);
|
||||
|
||||
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
|
||||
const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
|
||||
// appReady = ring filled + transition done, show main UI
|
||||
const [appReady, setAppReady] = useState(!settings.autoStartServer);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const [idle, setIdle] = useState(false);
|
||||
// dev tools: force show splash
|
||||
const [devSplash, setDevSplash] = useState(false);
|
||||
|
||||
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// expose devSplash trigger via window for settings
|
||||
useEffect(() => {
|
||||
(window as any).__mokuShowSplash = () => setDevSplash(true);
|
||||
return () => { delete (window as any).__mokuShowSplash; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appReady) return;
|
||||
function resetIdle() {
|
||||
setIdle(false);
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||
if (idleTimeoutMs === 0) return;
|
||||
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
|
||||
}
|
||||
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
|
||||
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
|
||||
resetIdle();
|
||||
return () => {
|
||||
events.forEach(e => window.removeEventListener(e, resetIdle));
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
};
|
||||
}, [appReady, settings.idleTimeoutMin]);
|
||||
|
||||
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
||||
for (const item of prev) {
|
||||
if (item.state !== "DOWNLOADING") continue;
|
||||
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
||||
const manga = item.chapter.manga;
|
||||
addToast({ kind:"success", title:"Chapter downloaded",
|
||||
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
||||
duration: 4000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyQueue(next: DownloadQueueItem[]) {
|
||||
detectCompletions(prevQueueRef.current, next);
|
||||
prevQueueRef.current = next;
|
||||
setActiveDownloads(next.map(item => ({
|
||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
||||
})));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
||||
}, [settings.uiScale]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = settings.theme ?? "dark";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const p = (e: MouseEvent) => e.preventDefault();
|
||||
document.addEventListener("contextmenu", p);
|
||||
return () => document.removeEventListener("contextmenu", p);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.autoStartServer) return;
|
||||
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
|
||||
console.warn("Could not start server:", err));
|
||||
return () => { invoke("kill_server").catch(() => {}); };
|
||||
}, [settings.autoStartServer, settings.serverBinary]);
|
||||
|
||||
// Poll until server responds
|
||||
useEffect(() => {
|
||||
if (serverProbeOk) return;
|
||||
let cancelled = false, tries = 0;
|
||||
async function probe() {
|
||||
if (cancelled) return;
|
||||
tries++;
|
||||
try {
|
||||
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({ query:"{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
|
||||
} catch {}
|
||||
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
|
||||
if (!cancelled) setTimeout(probe, 800);
|
||||
}
|
||||
const t = setTimeout(probe, 800);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [serverProbeOk, settings.serverUrl, retryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appReady) return;
|
||||
function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||
}
|
||||
poll();
|
||||
const id = setInterval(poll, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [appReady]);
|
||||
|
||||
useEffect(() => {
|
||||
type P = { chapterId:number; mangaId:number; progress:number }[];
|
||||
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
|
||||
return () => { unsub.then(fn => fn()); };
|
||||
}, [setActiveDownloads]);
|
||||
|
||||
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
|
||||
if (devSplash) {
|
||||
return (
|
||||
<SplashScreen
|
||||
mode="idle"
|
||||
showFps
|
||||
showCards={settings.splashCards ?? true}
|
||||
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading splash — shown until ring fills + transition completes
|
||||
if (!appReady) {
|
||||
return (
|
||||
<SplashScreen
|
||||
mode="loading"
|
||||
ringFull={serverProbeOk}
|
||||
failed={failed}
|
||||
showCards={settings.splashCards ?? true}
|
||||
onReady={() => setAppReady(true)}
|
||||
onRetry={() => {
|
||||
setFailed(false);
|
||||
setServerProbeOk(false);
|
||||
setRetryKey(k => k+1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
{idle && !activeChapter && (
|
||||
<SplashScreen
|
||||
mode="idle"
|
||||
showCards={settings.splashCards ?? true}
|
||||
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
|
||||
/>
|
||||
)}
|
||||
{!activeChapter && <TitleBar/>}
|
||||
<div className={s.content}>
|
||||
{activeChapter ? <Reader/> : <Layout/>}
|
||||
</div>
|
||||
{settingsOpen && <Settings/>}
|
||||
<MangaPreview/>
|
||||
<Toaster/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<g transform="translate(256,265) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,27 +1,22 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 500.000000 500.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)"
|
||||
fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" ry="112" fill="#091209"/>
|
||||
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,83 +0,0 @@
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-1);
|
||||
min-width: 190px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0,0,0,0.08),
|
||||
0 4px 12px rgba(0,0,0,0.35),
|
||||
0 16px 40px rgba(0,0,0,0.25);
|
||||
animation: scaleIn 0.1s ease both;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: 5px var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.item:hover:not(:disabled),
|
||||
.itemFocused:not(:disabled) {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Icon area — fixed-width column so labels align */
|
||||
.itemIconWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.item:hover .itemIconWrap,
|
||||
.itemFocused .itemIconWrap {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
flex: 1;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Danger variant */
|
||||
.itemDanger { color: var(--color-error); }
|
||||
.itemDanger:hover:not(:disabled),
|
||||
.itemDanger.itemFocused:not(:disabled) {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
.itemIconDanger { color: var(--color-error) !important; opacity: 0.7; }
|
||||
|
||||
/* Disabled */
|
||||
.itemDisabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background: var(--border-dim);
|
||||
margin: 3px var(--sp-1);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import s from "./ContextMenu.module.css";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
separator?: never;
|
||||
}
|
||||
|
||||
export interface ContextMenuSeparator {
|
||||
separator: true;
|
||||
label?: never;
|
||||
icon?: never;
|
||||
onClick?: never;
|
||||
danger?: never;
|
||||
disabled?: never;
|
||||
}
|
||||
|
||||
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [focused, setFocused] = useState<number>(-1);
|
||||
|
||||
// Build list of actionable (non-separator, non-disabled) indices for keyboard nav
|
||||
const actionable = items
|
||||
.map((_, i) => i)
|
||||
.filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled);
|
||||
|
||||
useEffect(() => {
|
||||
function onDown(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocused((prev) => {
|
||||
const cur = actionable.indexOf(prev);
|
||||
return actionable[(cur + 1) % actionable.length] ?? actionable[0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocused((prev) => {
|
||||
const cur = actionable.indexOf(prev);
|
||||
return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && focused >= 0) {
|
||||
e.preventDefault();
|
||||
const item = items[focused] as ContextMenuItem;
|
||||
if (item && !item.disabled) { item.onClick(); onClose(); }
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [onClose, focused, actionable, items]);
|
||||
|
||||
// Focus first item on open
|
||||
useEffect(() => {
|
||||
if (actionable.length) setFocused(actionable[0]);
|
||||
}, []);
|
||||
|
||||
const getPosition = useCallback(() => {
|
||||
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
||||
const scaledX = x / zoom;
|
||||
const scaledY = y / zoom;
|
||||
const menuW = 200;
|
||||
const menuH = items.length * 34;
|
||||
const vw = window.innerWidth / zoom;
|
||||
const vh = window.innerHeight / zoom;
|
||||
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
||||
const top = scaledY + menuH > vh ? scaledY - menuH : scaledY;
|
||||
return { left: Math.max(4, left), top: Math.max(4, top) };
|
||||
}, [x, y, items.length]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={s.menu}
|
||||
style={getPosition()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if ("separator" in item && item.separator) {
|
||||
return <div key={i} className={s.separator} />;
|
||||
}
|
||||
const mi = item as ContextMenuItem;
|
||||
const isFocused = focused === i;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className={[
|
||||
s.item,
|
||||
mi.danger ? s.itemDanger : "",
|
||||
mi.disabled ? s.itemDisabled : "",
|
||||
isFocused ? s.itemFocused : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
||||
onMouseEnter={() => !mi.disabled && setFocused(i)}
|
||||
onMouseLeave={() => setFocused(-1)}
|
||||
disabled={mi.disabled}
|
||||
>
|
||||
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
|
||||
{mi.icon ?? null}
|
||||
</span>
|
||||
<span className={s.itemLabel}>{mi.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
.root {
|
||||
padding: var(--sp-6);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.headerActions { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.iconBtn {
|
||||
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);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
/* Loading state — accent tint so it's visually distinct */
|
||||
.iconBtnLoading {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.iconBtnLoading:hover:not(:disabled) {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
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);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
|
||||
.statusDotActive {
|
||||
background: var(--accent);
|
||||
animation: pulse 1.6s ease infinite;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
|
||||
.row {
|
||||
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);
|
||||
transition: border-color var(--t-fast), opacity var(--t-base);
|
||||
}
|
||||
|
||||
.rowActive { border-color: var(--accent-dim); }
|
||||
|
||||
/* Fade out rows being removed */
|
||||
.rowRemoving { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
/* Thumbnail */
|
||||
.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);
|
||||
}
|
||||
|
||||
.thumbImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Info block */
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mangaTitle {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chapterName {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pagesLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.progressWrap {
|
||||
height: 2px;
|
||||
background: var(--border-base);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* Right side */
|
||||
.rowRight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stateLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
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);
|
||||
}
|
||||
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.removeBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 160px;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER,
|
||||
CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { DownloadStatus } from "../../lib/types";
|
||||
import s from "./DownloadQueue.module.css";
|
||||
|
||||
export default function DownloadQueue() {
|
||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [togglingPlay, setTogglingPlay] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
|
||||
// Apply status to local state + global store.
|
||||
// Completion toasting is handled globally in App.tsx — no duplication here.
|
||||
const applyStatus = useCallback((ds: DownloadStatus) => {
|
||||
setStatus(ds);
|
||||
setActiveDownloads(
|
||||
ds.queue.map((item) => ({
|
||||
chapterId: item.chapter.id,
|
||||
mangaId: item.chapter.mangaId,
|
||||
progress: item.progress,
|
||||
}))
|
||||
);
|
||||
}, [setActiveDownloads]);
|
||||
|
||||
async function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then((d) => applyStatus(d.downloadStatus))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
poll();
|
||||
const id = setInterval(poll, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function togglePlay() {
|
||||
if (togglingPlay) return;
|
||||
setTogglingPlay(true);
|
||||
const wasRunning = status?.state === "STARTED";
|
||||
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
|
||||
try {
|
||||
if (wasRunning) {
|
||||
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
||||
applyStatus(d.stopDownloader.downloadStatus);
|
||||
} else {
|
||||
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
||||
applyStatus(d.startDownloader.downloadStatus);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setTogglingPlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
if (clearing) return;
|
||||
setClearing(true);
|
||||
setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
|
||||
setActiveDownloads([]);
|
||||
try {
|
||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||
applyStatus(d.clearDownloader.downloadStatus);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function dequeue(chapterId: number) {
|
||||
if (dequeueing.has(chapterId)) return;
|
||||
setDequeueing((prev) => new Set(prev).add(chapterId));
|
||||
setStatus((prev) =>
|
||||
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
|
||||
);
|
||||
try {
|
||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||
poll();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setDequeueing((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(chapterId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const queue = status?.queue ?? [];
|
||||
const isRunning = status?.state === "STARTED";
|
||||
|
||||
function pagesDownloaded(progress: number, pageCount: number): number {
|
||||
return Math.round(progress * pageCount);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Downloads</h1>
|
||||
<div className={s.headerActions}>
|
||||
<button
|
||||
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||
onClick={togglePlay}
|
||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)}
|
||||
title={isRunning ? "Pause" : "Resume"}
|
||||
>
|
||||
{togglingPlay ? (
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||
) : isRunning ? (
|
||||
<Pause size={14} weight="fill" />
|
||||
) : (
|
||||
<Play size={14} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||
onClick={clear}
|
||||
disabled={clearing || queue.length === 0}
|
||||
title="Clear queue"
|
||||
>
|
||||
{clearing ? (
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||
) : (
|
||||
<Trash size={14} weight="regular" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.statusBar}>
|
||||
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
||||
<span className={s.statusText}>
|
||||
{togglingPlay
|
||||
? (isRunning ? "Pausing…" : "Starting…")
|
||||
: isRunning ? "Downloading" : "Paused"}
|
||||
</span>
|
||||
<span className={s.statusCount}>{queue.length} queued</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : queue.length === 0 ? (
|
||||
<div className={s.empty}>Queue is empty.</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{queue.map((item, i) => {
|
||||
const isActive = i === 0 && isRunning;
|
||||
const pages = item.chapter.pageCount ?? 0;
|
||||
const done = pagesDownloaded(item.progress, pages);
|
||||
const manga = item.chapter.manga;
|
||||
const isRemoving = dequeueing.has(item.chapter.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.chapter.id}
|
||||
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
|
||||
>
|
||||
{manga?.thumbnailUrl && (
|
||||
<div className={s.thumb}>
|
||||
<img
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.thumbImg}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.info}>
|
||||
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
|
||||
<span className={s.chapterName}>{item.chapter.name}</span>
|
||||
{pages > 0 && (
|
||||
<span className={s.pagesLabel}>
|
||||
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className={s.progressWrap}>
|
||||
<div
|
||||
className={s.progressBar}
|
||||
style={{ width: `${Math.round(item.progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.rowRight}>
|
||||
<span className={s.stateLabel}>{item.state}</span>
|
||||
{!isActive && (
|
||||
<button
|
||||
className={s.removeBtn}
|
||||
onClick={() => dequeue(item.chapter.id)}
|
||||
disabled={isRemoving}
|
||||
title="Remove from queue"
|
||||
>
|
||||
{isRemoving
|
||||
? <CircleNotch size={11} weight="light" className="anim-spin" />
|
||||
: <X size={12} weight="light" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
/* ── Header / Tab switcher ───────────────────────────────────────────────── */
|
||||
.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);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
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);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
|
||||
.tabActive {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
|
||||
/* Source picker */
|
||||
.sourcePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.sourcePickerLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sourceSelect {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-ui);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--t-base);
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.sourceSelect:focus { border-color: var(--border-strong); }
|
||||
|
||||
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-5) 0 var(--sp-6);
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────────────── */
|
||||
.section {
|
||||
margin-bottom: var(--sp-6);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-6) var(--sp-3);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
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;
|
||||
}
|
||||
|
||||
.sectionTitleIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.seeAll {
|
||||
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);
|
||||
}
|
||||
|
||||
.seeAll:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Horizontal scroll row ───────────────────────────────────────────────── */
|
||||
.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 (shared by all rows) ───────────────────────────────────────────── */
|
||||
.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); }
|
||||
|
||||
.coverWrap {
|
||||
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;
|
||||
}
|
||||
|
||||
.inLibraryBadge {
|
||||
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);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
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 — invisible placeholder to fill row trailing space */
|
||||
.ghostCard {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
aspect-ratio: 2 / 3;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
||||
.skeletonRow {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardSkeleton { flex-shrink: 0; width: 110px; }
|
||||
|
||||
.coverSkeleton {
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.titleSkeleton {
|
||||
height: 11px;
|
||||
margin-top: var(--sp-2);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
/* ── Genre drill-down grid ───────────────────────────────────────────────── */
|
||||
.drillRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.drillHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.drillTitle {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.drillGrid {
|
||||
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;
|
||||
}
|
||||
|
||||
.drillCard {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.drillCard:hover .cover { filter: brightness(1.06); }
|
||||
.drillCard:hover .title { color: var(--text-primary); }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.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;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── No source state ─────────────────────────────────────────────────────── */
|
||||
.noSource {
|
||||
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);
|
||||
}
|
||||
/* ── Explore More end-cap card ───────────────────────────────────────────── */
|
||||
.exploreMoreCard {
|
||||
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;
|
||||
}
|
||||
.exploreMoreCard:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
|
||||
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
|
||||
|
||||
.exploreMoreInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.exploreMoreIcon {
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.exploreMoreLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exploreMoreGenre {
|
||||
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);
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useRef, memo } from "react";
|
||||
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||
import GenreDrillPage from "./GenreDrillPage";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import SourceList from "../sources/SourceList";
|
||||
import SourceBrowse from "../sources/SourceBrowse";
|
||||
import s from "./Explore.module.css";
|
||||
|
||||
// ── Frecency score ────────────────────────────────────────────────────────────
|
||||
|
||||
function frecencyScore(readAt: number, count: number): number {
|
||||
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
||||
return count / Math.log(hoursSince + 2);
|
||||
}
|
||||
|
||||
// ── Ghost / Skeleton ──────────────────────────────────────────────────────────
|
||||
|
||||
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
|
||||
const GHOST_COUNT = 3;
|
||||
const ROW_CAP = 25;
|
||||
|
||||
// Hijack vertical wheel delta → horizontal scroll on .row divs
|
||||
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
const el = e.currentTarget;
|
||||
const canScrollLeft = el.scrollLeft > 0;
|
||||
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
|
||||
if (!canScrollLeft && !canScrollRight) return;
|
||||
e.stopPropagation();
|
||||
el.scrollLeft += e.deltaY;
|
||||
}
|
||||
|
||||
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.skeletonRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cover image with fade-in ──────────────────────────────────────────────────
|
||||
|
||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Mini card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MiniCard = memo(function MiniCard({
|
||||
manga, onClick, onContextMenu, subtitle, progress,
|
||||
}: {
|
||||
manga: Manga;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
subtitle?: string;
|
||||
progress?: number;
|
||||
}) {
|
||||
return (
|
||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
{progress !== undefined && progress > 0 && (
|
||||
<div className={s.progressBar}>
|
||||
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.title}>{manga.title}</p>
|
||||
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Explore More end-cap ──────────────────────────────────────────────────────
|
||||
|
||||
const ExploreMoreCard = memo(function ExploreMoreCard({
|
||||
genre, onClick,
|
||||
}: { genre: string; onClick: () => void }) {
|
||||
return (
|
||||
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
|
||||
<div className={s.exploreMoreInner}>
|
||||
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
|
||||
<span className={s.exploreMoreLabel}>Explore more</span>
|
||||
<span className={s.exploreMoreGenre}>{genre}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Section ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
title, icon, onSeeAll, loading, children,
|
||||
}: {
|
||||
title: string; icon?: React.ReactNode; onSeeAll?: () => void;
|
||||
loading?: boolean; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.section}>
|
||||
<div className={s.sectionHeader}>
|
||||
<span className={s.sectionTitle}>
|
||||
<span className={s.sectionTitleIcon}>{icon}{title}</span>
|
||||
</span>
|
||||
{onSeeAll && (
|
||||
<button className={s.seeAll} onClick={onSeeAll}>
|
||||
See all <ArrowRight size={11} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? <SkeletonRow /> : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
type ExploreMode = "explore" | "sources";
|
||||
|
||||
export default function Explore() {
|
||||
const [mode, setMode] = useState<ExploreMode>("explore");
|
||||
const activeSource = useStore((s) => s.activeSource);
|
||||
const genreFilter = useStore((s) => s.genreFilter);
|
||||
|
||||
if (activeSource) return <SourceBrowse />;
|
||||
if (genreFilter) return <GenreDrillPage />;
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Explore</h1>
|
||||
<div className={s.tabs}>
|
||||
<button
|
||||
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("explore")}
|
||||
>
|
||||
<Compass size={11} weight="bold" /> Explore
|
||||
</button>
|
||||
<button
|
||||
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("sources")}
|
||||
>
|
||||
<List size={11} weight="bold" /> Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Keep ExploreFeed always mounted so data survives tab switches */}
|
||||
<div style={{ display: mode === "explore" ? "contents" : "none" }}><ExploreFeed /></div>
|
||||
{mode === "sources" && <SourceList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Explore feed ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
||||
|
||||
function ExploreFeed() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loadingLib, setLoadingLib] = useState(true);
|
||||
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
||||
const [loadingPopular, setLoadingPopular] = useState(true);
|
||||
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fetchedGenresRef = useRef<string>("");
|
||||
|
||||
const history = useStore((s) => s.history);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setPreviewManga = useStore((s) => s.setPreviewManga);
|
||||
const setGenreFilter = useStore((s) => s.setGenreFilter);
|
||||
const folders = useStore((s) => s.settings.folders);
|
||||
const addFolder = useStore((s) => s.addFolder);
|
||||
const assignMangaToFolder = useStore((s) => s.assignMangaToFolder);
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => { cache.clear(CACHE_KEYS.LIBRARY); })
|
||||
.catch(console.error),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...folders.map((f): ContextMenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Library + sources load (retries when suwayomi wasn't ready) ─────────────
|
||||
useEffect(() => {
|
||||
// If we already have data, no need to re-fetch (cache hit path)
|
||||
const alreadyLoaded = allManga.length > 0 && sources.length > 0;
|
||||
if (alreadyLoaded) return;
|
||||
|
||||
setLoadingLib(true);
|
||||
setLoadingPopular(true);
|
||||
setLoadError(false);
|
||||
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
|
||||
// Clear stale failed cache entries so we actually retry
|
||||
if (retryCount > 0) {
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
cache.clear(CACHE_KEYS.SOURCES);
|
||||
fetchedGenresRef.current = "";
|
||||
}
|
||||
|
||||
// Library — fire immediately, independent of sources
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
]).then(([all, lib]) => {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
||||
})
|
||||
).then(setAllManga)
|
||||
.catch((e) => { console.error(e); setLoadError(true); })
|
||||
.finally(() => setLoadingLib(false));
|
||||
|
||||
// Sources — then kick off popular AND genres simultaneously
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
||||
).then((allSources) => {
|
||||
if (allSources.length === 0) { setLoadingPopular(false); setLoadError(true); return; }
|
||||
|
||||
// Cap to 2 sources for the explore feed — halves the network calls
|
||||
const topSources = getTopSources(allSources).slice(0, 2);
|
||||
setSources(allSources);
|
||||
|
||||
// ── Popular — don't block genres ──────────────────────────────────
|
||||
cache.get(CACHE_KEYS.POPULAR, () =>
|
||||
Promise.allSettled(
|
||||
topSources.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(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
|
||||
|
||||
// ── Genres — start immediately alongside popular using foundational
|
||||
// genres as a starting point; personalized genres replace these once
|
||||
// library loads. Results stream in as each genre resolves.
|
||||
const genresToFetch = FOUNDATIONAL_GENRES.slice(0, 3);
|
||||
const genreKey = genresToFetch.join(",");
|
||||
if (fetchedGenresRef.current === genreKey) return;
|
||||
fetchedGenresRef.current = genreKey;
|
||||
|
||||
setLoadingGenres(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
const streamingMap = new Map<string, Manga[]>();
|
||||
Promise.allSettled(
|
||||
genresToFetch.map((genre) =>
|
||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||
Promise.allSettled(
|
||||
topSources.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "SEARCH", page: 1, query: genre,
|
||||
}, ctrl.signal).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, 24);
|
||||
})
|
||||
).then((mangas) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Stream: each genre paints immediately as it resolves
|
||||
streamingMap.set(genre, mangas);
|
||||
setGenreResults(new Map(streamingMap));
|
||||
})
|
||||
)
|
||||
)
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
||||
})
|
||||
.catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [retryCount]);
|
||||
|
||||
// ── Frecency genres (derived from history + library) ──────────────────────
|
||||
const frecencyGenres = useMemo(() => {
|
||||
const mangaScores = new Map<number, number>();
|
||||
const mangaReadAt = new Map<number, number>();
|
||||
for (const entry of history) {
|
||||
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
|
||||
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
|
||||
mangaReadAt.set(entry.mangaId, entry.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 genre of mangaMap.get(mangaId)?.genre ?? [])
|
||||
genreWeights.set(genre, (genreWeights.get(genre) ?? 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);
|
||||
}, [allManga, history]);
|
||||
|
||||
// ── Re-fetch only when personalized genres differ from what's cached ───────
|
||||
useEffect(() => {
|
||||
if (frecencyGenres.length === 0 || sources.length === 0) return;
|
||||
|
||||
const genreKey = frecencyGenres.join(",");
|
||||
if (fetchedGenresRef.current === genreKey) return; // already fetched, cache hit
|
||||
fetchedGenresRef.current = genreKey;
|
||||
|
||||
setLoadingGenres(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
const topSources = getTopSources(sources).slice(0, 2);
|
||||
const streamingMap = new Map<string, Manga[]>();
|
||||
|
||||
Promise.allSettled(
|
||||
frecencyGenres.map((genre) =>
|
||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||
Promise.allSettled(
|
||||
topSources.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "SEARCH", page: 1, query: genre,
|
||||
}, ctrl.signal).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, 24);
|
||||
})
|
||||
).then((mangas) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
streamingMap.set(genre, mangas);
|
||||
setGenreResults(new Map(streamingMap));
|
||||
})
|
||||
)
|
||||
)
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
||||
}, [frecencyGenres, sources]);
|
||||
|
||||
function openManga(m: Manga) { setPreviewManga(m); }
|
||||
|
||||
// ── Continue reading ──────────────────────────────────────────────────────
|
||||
const continueReading = useMemo(() => {
|
||||
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 entry of history) {
|
||||
if (seen.has(entry.mangaId)) continue;
|
||||
seen.add(entry.mangaId);
|
||||
const manga = mangaMap.get(entry.mangaId);
|
||||
if (!manga) continue;
|
||||
result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 });
|
||||
if (result.length >= 12) break;
|
||||
}
|
||||
return result;
|
||||
}, [history, allManga]);
|
||||
|
||||
// ── Recommended ───────────────────────────────────────────────────────────
|
||||
const recommended = useMemo(() => {
|
||||
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
|
||||
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);
|
||||
}, [allManga, frecencyGenres, continueReading]);
|
||||
|
||||
const genresLoading = loadingGenres;
|
||||
|
||||
return (
|
||||
<div className={s.body}>
|
||||
|
||||
{(continueReading.length > 0 || loadingLib) && (
|
||||
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
|
||||
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
|
||||
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-cr-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(recommended.length > 0 || loadingLib) && (
|
||||
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{recommended.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(popularManga.length > 0 || loadingPopular) && (
|
||||
<Section
|
||||
title={sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
||||
icon={<Fire size={11} weight="bold" />}
|
||||
loading={loadingPopular}
|
||||
>
|
||||
{sources.length === 0 ? (
|
||||
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||
) : (
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{popularManga.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{frecencyGenres.map((genre) => {
|
||||
const items = genreResults.get(genre) ?? [];
|
||||
const isLoading = genresLoading && items.length === 0;
|
||||
if (!isLoading && items.length === 0) return null;
|
||||
return (
|
||||
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{items.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{items.length >= ROW_CAP && (
|
||||
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
|
||||
)}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loadingLib && !loadingPopular && !loadingGenres &&
|
||||
continueReading.length === 0 && recommended.length === 0 &&
|
||||
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
||||
<div className={s.empty}>
|
||||
{loadError ? (
|
||||
<>
|
||||
<span>Could not reach Suwayomi</span>
|
||||
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
|
||||
<button
|
||||
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Nothing to explore yet</span>
|
||||
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.loadingHint {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* Grid fills entire remaining height, no show-more needed */
|
||||
.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;
|
||||
/* Smooth GPU-accelerated scrolling */
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .cardTitle { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
/* Solid bg shown while image fades in — matches skeleton color */
|
||||
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;
|
||||
}
|
||||
|
||||
.inLibraryBadge {
|
||||
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);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Skeletons */
|
||||
.cardSkeleton { padding: 0; }
|
||||
.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); }
|
||||
.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.resultCount {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* Show more — spans full grid width */
|
||||
.showMoreCell {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-2) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
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: 7px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.showMoreBtn:hover:not(:disabled) {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.showMoreBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||
import { useStore } from "../../store";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import s from "./GenreDrillPage.module.css";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 50; // how many items to show at once
|
||||
const INITIAL_PAGES = 3; // source API pages to fetch upfront per source
|
||||
const MAX_SOURCES = 12; // max sources to query concurrently
|
||||
const CONCURRENCY = 4; // parallel source fetches
|
||||
|
||||
async function runConcurrent<T>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < items.length) {
|
||||
if (signal.aborted) return;
|
||||
const item = items[i++];
|
||||
await fn(item).catch(() => {});
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||
}
|
||||
|
||||
// ── CoverImg ──────────────────────────────────────────────────────────────────
|
||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// ── GenreDrillPage ────────────────────────────────────────────────────────────
|
||||
export default function GenreDrillPage() {
|
||||
const genre = useStore((st) => st.genreFilter);
|
||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||
const settings = useStore((st) => st.settings);
|
||||
const folders = useStore((st) => st.settings.folders);
|
||||
const addFolder = useStore((st) => st.addFolder);
|
||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||
|
||||
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
|
||||
// Per-source next-page tracker; -1 means exhausted
|
||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||
const sourcesRef = useRef<Source[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!genre) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
setLoadingInitial(true);
|
||||
setSourceManga([]);
|
||||
setLibraryManga([]);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
nextPageRef.current = new Map();
|
||||
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
|
||||
// ── Library (fire-and-forget, doesn't block skeleton removal) ─────────
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
]).then(([all, lib]) => {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
||||
})
|
||||
)
|
||||
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||
|
||||
// ── Sources: stream results in as each source responds ────────────────
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang))
|
||||
).then(async (allSources) => {
|
||||
const sources = allSources.slice(0, MAX_SOURCES);
|
||||
sourcesRef.current = sources;
|
||||
// Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page
|
||||
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
||||
|
||||
await runConcurrent(sources, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageItems: Manga[] = [];
|
||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||
ctrl.signal,
|
||||
);
|
||||
pageItems.push(...d.fetchSourceManga.mangas);
|
||||
if (!d.fetchSourceManga.hasNextPage) {
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
break;
|
||||
} else if (page === INITIAL_PAGES) {
|
||||
// Has more pages beyond what we fetched upfront — mark for "load more"
|
||||
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||
// Dedupe by ID only — title dedup across sources is too aggressive and collapses
|
||||
// legitimate different-source results that share a common title (e.g. "Action" genre)
|
||||
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
||||
// Drop the skeleton as soon as we have anything
|
||||
setLoadingInitial(false);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||
}).catch((e) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||
});
|
||||
|
||||
return () => { ctrl.abort(); };
|
||||
}, [genre]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Derived merged list ────────────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre));
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
const srcAll = sourceManga.filter((m) => !libIds.has(m.id));
|
||||
return dedupeMangaById([...libMatches, ...srcAll]);
|
||||
}, [libraryManga, sourceManga, genre]);
|
||||
|
||||
// ── Load more ──────────────────────────────────────────────────────────────
|
||||
const hasMoreVisible = visibleCount < filtered.length;
|
||||
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||
const hasMore = hasMoreVisible || hasMoreNetwork;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore) return;
|
||||
|
||||
// If there are buffered results, just reveal the next page
|
||||
if (hasMoreVisible) {
|
||||
setVisibleCount((v) => v + PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch next pages from network
|
||||
const sources = sourcesRef.current.filter(
|
||||
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
||||
);
|
||||
if (!sources.length) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
await runConcurrent(sources, async (src) => {
|
||||
const page = nextPageRef.current.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||
ctrl.signal,
|
||||
);
|
||||
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
||||
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0)
|
||||
setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setVisibleCount((v) => v + PAGE_SIZE);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [loadingMore, hasMoreVisible, genre]);
|
||||
|
||||
// ── Context menu ──────────────────────────────────────────────────────────
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => {
|
||||
setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
})
|
||||
.catch(console.error),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...folders.map((f): ContextMenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const visibleItems = filtered.slice(0, visibleCount);
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<button className={s.back} onClick={() => setGenreFilter("")}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<span className={s.title}>{genre}</span>
|
||||
{loadingInitial && filtered.length === 0 ? null : (
|
||||
<span className={s.resultCount}>
|
||||
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
|
||||
</span>
|
||||
)}
|
||||
{!loadingInitial && hasMoreNetwork && (
|
||||
<span className={s.loadingHint}>More loading…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingInitial && filtered.length === 0 ? (
|
||||
<div className={s.grid}>
|
||||
{Array.from({ length: 50 }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.empty}>No manga found for "{genre}".</div>
|
||||
) : (
|
||||
<div className={s.grid}>
|
||||
{visibleItems.map((m) => (
|
||||
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
</div>
|
||||
<p className={s.cardTitle}>{m.title}</p>
|
||||
</button>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className={s.showMoreCell}>
|
||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display:"inline-block" }} /> Loading…</>
|
||||
: `Show more`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
/* ── Animations ──────────────────────────────────────────────────────────── */
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||
|
||||
/* ── Backdrop ────────────────────────────────────────────────────────────── */
|
||||
.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;
|
||||
animation: fadeIn 0.12s ease both;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* ── Modal shell ─────────────────────────────────────────────────────────── */
|
||||
.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), 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* ── Cover column ────────────────────────────────────────────────────────── */
|
||||
.coverCol {
|
||||
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-y: auto; overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.coverCol::-webkit-scrollbar { display: none; }
|
||||
|
||||
.coverWrap {
|
||||
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;
|
||||
}
|
||||
|
||||
.coverSpinner {
|
||||
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);
|
||||
}
|
||||
|
||||
.coverActions {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ── Cover action buttons ────────────────────────────────────────────────── */
|
||||
.actionBtn {
|
||||
display: flex; align-items: center; justify-content: 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;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||
.actionBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.actionBtnActive {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
|
||||
.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
|
||||
.actionBtnLabel {
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
||||
.folderWrap { position: relative; width: 100%; }
|
||||
|
||||
.folderMenu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-1);
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
z-index: 10;
|
||||
animation: scaleIn 0.1s ease both;
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
.folderEmpty {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
.folderItem {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
background: none; border: none; cursor: pointer; text-align: left;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.folderItemOn { color: var(--accent-fg); }
|
||||
|
||||
.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||
|
||||
.folderCreateRow {
|
||||
display: flex; gap: var(--sp-1); padding: var(--sp-1);
|
||||
}
|
||||
.folderInput {
|
||||
flex: 1; background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm); padding: 4px 8px;
|
||||
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
outline: none; min-width: 0;
|
||||
}
|
||||
.folderInput:focus { border-color: var(--border-focus); }
|
||||
|
||||
.folderOkBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
padding: 4px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.folderOkBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
|
||||
.folderNewBtn {
|
||||
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); background: none; border: none;
|
||||
cursor: pointer; text-align: left; width: 100%;
|
||||
transition: color var(--t-fast);
|
||||
}
|
||||
.folderNewBtn:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Content column ──────────────────────────────────────────────────────── */
|
||||
.content {
|
||||
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||
.contentHeader {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titleBlock {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-lg); font-weight: var(--weight-medium);
|
||||
color: var(--text-primary); letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.byline {
|
||||
font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.skByline {
|
||||
height: 14px; width: 55%;
|
||||
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||
animation: pulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); border: none; background: none;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||
.contentBody {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
display: flex; flex-direction: column; gap: var(--sp-4);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* ── Error banner ────────────────────────────────────────────────────────── */
|
||||
.errorBanner {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--color-warn, #f59e0b);
|
||||
background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent);
|
||||
border-radius: var(--radius-sm); padding: 6px var(--sp-3);
|
||||
}
|
||||
|
||||
/* ── Skeleton rows ───────────────────────────────────────────────────────── */
|
||||
.skRow {
|
||||
display: flex; gap: var(--sp-2); align-items: center;
|
||||
}
|
||||
.skBadge {
|
||||
height: 20px; width: 54px;
|
||||
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||
animation: pulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.skDesc {
|
||||
display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0;
|
||||
}
|
||||
.skLine {
|
||||
height: 13px; background: var(--bg-overlay);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: pulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────────────────────── */
|
||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||
|
||||
.badge {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||
padding: 3px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised); color: var(--text-faint);
|
||||
}
|
||||
.badgeGreen {
|
||||
background: color-mix(in srgb, #22c55e 12%, transparent);
|
||||
border-color: color-mix(in srgb, #22c55e 30%, transparent);
|
||||
color: #22c55e;
|
||||
}
|
||||
.badgeDim { /* default */ }
|
||||
.badgeAccent {
|
||||
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||
}
|
||||
.badgeUnread {
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
border-color: color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
color: #f59e0b;
|
||||
}
|
||||
.badgeNsfw {
|
||||
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ── Chapter box — clearly separated from description ────────────────────── */
|
||||
.chapterBox {
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.chapterLoading {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
}
|
||||
.chapterLoadingLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.chapterMeta {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.chapterLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.dlAllBtn {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
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);
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.dlAllBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.progressTrack {
|
||||
height: 3px; background: var(--bg-overlay);
|
||||
border-radius: var(--radius-full); overflow: hidden;
|
||||
}
|
||||
.progressFill {
|
||||
height: 100%; background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.readBtn {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 8px var(--sp-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||
cursor: pointer; align-self: flex-start;
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.readBtn:hover { filter: brightness(1.1); }
|
||||
|
||||
/* ── Description block ───────────────────────────────────────────────────── */
|
||||
.descBlock {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: var(--text-sm); color: var(--text-muted);
|
||||
line-height: var(--leading-base);
|
||||
display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.descOpen {
|
||||
display: block; -webkit-line-clamp: unset; overflow: visible;
|
||||
}
|
||||
|
||||
.descToggle {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); background: none; border: none;
|
||||
cursor: pointer; padding: 0; align-self: flex-start;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.descToggle:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Genre tags ──────────────────────────────────────────────────────────── */
|
||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
|
||||
.genreTag {
|
||||
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);
|
||||
}
|
||||
|
||||
.genreTagClickable {
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.genreTagClickable:hover {
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent-dim);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
/* ── Metadata table ──────────────────────────────────────────────────────── */
|
||||
.metaTable {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0;
|
||||
}
|
||||
.metaKey {
|
||||
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;
|
||||
}
|
||||
.metaVal {
|
||||
font-size: var(--text-sm); color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
.metaLink {
|
||||
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);
|
||||
}
|
||||
.metaLink:hover { opacity: 0.75; }
|
||||
@@ -1,569 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import {
|
||||
X, BookmarkSimple, ArrowSquareOut, Play,
|
||||
CircleNotch, Books, CaretDown, FolderSimplePlus, Folder,
|
||||
} from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import s from "./MangaPreview.module.css";
|
||||
|
||||
export default function MangaPreview() {
|
||||
const previewManga = useStore((st) => st.previewManga);
|
||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||
const setActiveManga = useStore((st) => st.setActiveManga);
|
||||
const setNavPage = useStore((st) => st.setNavPage);
|
||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||
const openReader = useStore((st) => st.openReader);
|
||||
const addToast = useStore((st) => st.addToast);
|
||||
const folders = useStore((st) => st.settings.folders);
|
||||
const addFolder = useStore((st) => st.addFolder);
|
||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
||||
|
||||
const [manga, setManga] = useState<Manga | null>(null);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [loadingChapters, setLoadingChapters] = useState(false);
|
||||
const [togglingLib, setTogglingLib] = useState(false);
|
||||
const [descExpanded, setDescExpanded] = useState(false);
|
||||
const [folderOpen, setFolderOpen] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [queueingAll, setQueueingAll] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const detailAbort = useRef<AbortController | null>(null);
|
||||
const chapterAbort = useRef<AbortController | null>(null);
|
||||
const folderRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
detailAbort.current?.abort();
|
||||
chapterAbort.current?.abort();
|
||||
setPreviewManga(null);
|
||||
setManga(null);
|
||||
setChapters([]);
|
||||
setDescExpanded(false);
|
||||
setFolderOpen(false);
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
setFetchError(null);
|
||||
}, [setPreviewManga]);
|
||||
|
||||
// ── Fetch detail + chapters on open ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!previewManga) return;
|
||||
|
||||
// Abort any in-flight requests from previous manga
|
||||
detailAbort.current?.abort();
|
||||
chapterAbort.current?.abort();
|
||||
|
||||
const dCtrl = new AbortController();
|
||||
const cCtrl = new AbortController();
|
||||
detailAbort.current = dCtrl;
|
||||
chapterAbort.current = cCtrl;
|
||||
|
||||
setManga(null);
|
||||
setChapters([]);
|
||||
setDescExpanded(false);
|
||||
setFetchError(null);
|
||||
setLoadingDetail(true);
|
||||
setLoadingChapters(true);
|
||||
|
||||
const id = previewManga.id;
|
||||
|
||||
// ── Detail fetch strategy ─────────────────────────────────────────────
|
||||
// For source/explore manga we must call FETCH_MANGA (mutation that
|
||||
// hits the source and syncs to the local DB). GET_MANGA only works for
|
||||
// manga already in the local DB with full metadata.
|
||||
//
|
||||
// Fast path: if we already cached a full record, use it directly.
|
||||
// Slow path: always try FETCH_MANGA first — it never fails for valid IDs
|
||||
// and returns the richest data. Fall back to GET_MANGA if it errors.
|
||||
//
|
||||
(async (): Promise<Manga> => {
|
||||
const cacheKey = CACHE_KEYS.MANGA(id);
|
||||
|
||||
// Already have a cached rich record — no network needed
|
||||
if (cache.has(cacheKey)) {
|
||||
return cache.get(cacheKey, () =>
|
||||
Promise.resolve(previewManga as Manga)
|
||||
) as Promise<Manga>;
|
||||
}
|
||||
|
||||
// Try FETCH_MANGA first — works for all manga regardless of whether
|
||||
// they are in the local DB yet (it fetches from source and syncs).
|
||||
try {
|
||||
const d = await gql<{ fetchManga: { manga: Manga } }>(
|
||||
FETCH_MANGA, { id }, dCtrl.signal
|
||||
);
|
||||
return d.fetchManga.manga;
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") throw e;
|
||||
// FETCH_MANGA failed (e.g. source offline) — fall back to local DB
|
||||
const local = await gql<{ manga: Manga }>(
|
||||
GET_MANGA, { id }, dCtrl.signal
|
||||
).then((d) => d.manga);
|
||||
if (local) return local;
|
||||
throw new Error("Could not load manga details");
|
||||
}
|
||||
})()
|
||||
.then((fullManga) => {
|
||||
if (dCtrl.signal.aborted) return;
|
||||
// Cache the rich record so re-opening is instant
|
||||
if (!cache.has(CACHE_KEYS.MANGA(id))) {
|
||||
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
||||
}
|
||||
setManga(fullManga);
|
||||
setLoadingDetail(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e?.name === "AbortError") return;
|
||||
console.error("MangaPreview detail fetch:", e);
|
||||
// Show whatever sparse data we have from previewManga
|
||||
setManga(previewManga as Manga);
|
||||
setFetchError("Could not load full details — showing cached data");
|
||||
setLoadingDetail(false);
|
||||
});
|
||||
|
||||
// ── Chapter fetch — local DB first, fall back to source fetch ────────
|
||||
gql<{ chapters: { nodes: Chapter[] } }>(
|
||||
GET_CHAPTERS, { mangaId: id }, cCtrl.signal
|
||||
)
|
||||
.then(async (d) => {
|
||||
if (cCtrl.signal.aborted) return;
|
||||
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
// If no local chapters yet (explore/source manga), fetch from source
|
||||
if (nodes.length === 0) {
|
||||
try {
|
||||
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(
|
||||
FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal
|
||||
);
|
||||
if (!cCtrl.signal.aborted)
|
||||
nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
// Leave nodes empty — not a fatal error
|
||||
}
|
||||
}
|
||||
if (!cCtrl.signal.aborted) setChapters(nodes);
|
||||
})
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); });
|
||||
|
||||
return () => { dCtrl.abort(); cCtrl.abort(); };
|
||||
}, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Keyboard close ────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!previewManga) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [previewManga, close]);
|
||||
|
||||
// ── Folder outside click ──────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!folderOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (folderRef.current && !folderRef.current.contains(e.target as Node)) {
|
||||
setFolderOpen(false); setCreatingFolder(false); setNewFolderName("");
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [folderOpen]);
|
||||
|
||||
if (!previewManga) return null;
|
||||
|
||||
// Always show title/cover from previewManga immediately; upgrade to fetched manga when ready
|
||||
const displayManga = manga ?? previewManga;
|
||||
const totalCount = chapters.length;
|
||||
const readCount = chapters.filter((c) => c.isRead).length;
|
||||
const unreadCount = totalCount - readCount;
|
||||
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
||||
const bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
|
||||
const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false;
|
||||
|
||||
// Scanlators — deduplicated, non-empty
|
||||
const scanlators = [...new Set(
|
||||
chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim())
|
||||
)];
|
||||
|
||||
// Publication date range from chapter upload dates
|
||||
const uploadDates = chapters
|
||||
.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null)
|
||||
.filter((d): d is number => d !== null && !isNaN(d));
|
||||
const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
|
||||
const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
const statusLabel = displayManga.status
|
||||
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
|
||||
: null;
|
||||
|
||||
const continueChapter = (() => {
|
||||
if (!chapters.length) return null;
|
||||
const asc = [...chapters];
|
||||
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
||||
const firstUnread = asc.find((c) => !c.isRead);
|
||||
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
||||
return { ch: asc[0], label: "Read again" };
|
||||
})();
|
||||
|
||||
async function toggleLibrary() {
|
||||
if (!manga) return;
|
||||
setTogglingLib(true);
|
||||
const next = !manga.inLibrary;
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||
const updated = { ...manga, inLibrary: next };
|
||||
setManga(updated);
|
||||
// Update cache so subsequent opens reflect new state
|
||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
||||
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
setTogglingLib(false);
|
||||
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
||||
}
|
||||
|
||||
async function downloadAll() {
|
||||
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
||||
if (!ids.length) return;
|
||||
setQueueingAll(true);
|
||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
||||
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
||||
setQueueingAll(false);
|
||||
}
|
||||
|
||||
function openSeriesDetail() {
|
||||
setActiveManga(displayManga);
|
||||
setNavPage("library");
|
||||
close();
|
||||
}
|
||||
|
||||
function handleFolderCreate() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name || !previewManga) return;
|
||||
const newId = addFolder(name);
|
||||
assignMangaToFolder(newId, previewManga.id);
|
||||
setNewFolderName("");
|
||||
setCreatingFolder(false);
|
||||
}
|
||||
|
||||
const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={s.backdrop}
|
||||
ref={backdropRef}
|
||||
onClick={(e) => { if (e.target === backdropRef.current) close(); }}
|
||||
>
|
||||
<div className={s.modal} role="dialog" aria-label="Manga preview">
|
||||
|
||||
{/* ── Cover column ── */}
|
||||
<div className={s.coverCol}>
|
||||
<div className={s.coverWrap}>
|
||||
<img
|
||||
src={thumbUrl(previewManga.thumbnailUrl)}
|
||||
alt={displayManga.title}
|
||||
className={s.cover}
|
||||
/>
|
||||
{loadingDetail && (
|
||||
<div className={s.coverSpinner}>
|
||||
<CircleNotch size={18} weight="light" className="anim-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.coverActions}>
|
||||
<button
|
||||
className={[s.actionBtn, inLibrary ? s.actionBtnActive : ""].join(" ")}
|
||||
onClick={toggleLibrary}
|
||||
disabled={togglingLib || loadingDetail}
|
||||
>
|
||||
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
||||
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
|
||||
</button>
|
||||
|
||||
<button className={s.actionBtn} onClick={openSeriesDetail}>
|
||||
<Books size={13} weight="light" />
|
||||
Series Detail
|
||||
</button>
|
||||
|
||||
{/* Folder picker */}
|
||||
<div className={s.folderWrap} ref={folderRef}>
|
||||
<button
|
||||
className={[s.actionBtn, assignedFolders.length > 0 ? s.actionBtnFolder : ""].join(" ")}
|
||||
onClick={() => setFolderOpen((p) => !p)}
|
||||
>
|
||||
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
|
||||
<span className={s.actionBtnLabel}>
|
||||
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{folderOpen && (
|
||||
<div className={s.folderMenu}>
|
||||
{folders.length === 0 && !creatingFolder && (
|
||||
<p className={s.folderEmpty}>No folders yet</p>
|
||||
)}
|
||||
{folders.map((f) => {
|
||||
const isIn = f.mangaIds.includes(previewManga.id);
|
||||
return (
|
||||
<button key={f.id}
|
||||
className={[s.folderItem, isIn ? s.folderItemOn : ""].join(" ")}
|
||||
onClick={() => isIn
|
||||
? removeMangaFromFolder(f.id, previewManga.id)
|
||||
: assignMangaToFolder(f.id, previewManga.id)}
|
||||
>
|
||||
<Folder size={12} weight={isIn ? "fill" : "light"} />
|
||||
{isIn ? "✓ " : ""}{f.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className={s.folderDivider} />
|
||||
{creatingFolder ? (
|
||||
<div className={s.folderCreateRow}>
|
||||
<input autoFocus className={s.folderInput} placeholder="Folder name…"
|
||||
value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleFolderCreate();
|
||||
if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); }
|
||||
}}
|
||||
/>
|
||||
<button className={s.folderOkBtn} onClick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className={s.folderNewBtn} onClick={() => setCreatingFolder(true)}>+ New folder</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content column ── */}
|
||||
<div className={s.content}>
|
||||
|
||||
{/* Header — title visible immediately from previewManga */}
|
||||
<div className={s.contentHeader}>
|
||||
<div className={s.titleBlock}>
|
||||
<h2 className={s.title}>{displayManga.title}</h2>
|
||||
{loadingDetail
|
||||
? <div className={s.skByline} />
|
||||
: (displayManga.author || displayManga.artist)
|
||||
? <p className={s.byline}>
|
||||
{[displayManga.author, displayManga.artist]
|
||||
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
<button className={s.closeBtn} onClick={close}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className={s.contentBody}>
|
||||
|
||||
{/* Error banner */}
|
||||
{fetchError && (
|
||||
<div className={s.errorBanner}>{fetchError}</div>
|
||||
)}
|
||||
|
||||
{/* ── Badges ── */}
|
||||
{loadingDetail ? (
|
||||
<div className={s.skRow}>
|
||||
<div className={s.skBadge} />
|
||||
<div className={s.skBadge} style={{ width: 72 }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.badges}>
|
||||
{statusLabel && (
|
||||
<span className={[s.badge,
|
||||
displayManga.status === "ONGOING" ? s.badgeGreen : s.badgeDim
|
||||
].join(" ")}>{statusLabel}</span>
|
||||
)}
|
||||
{displayManga.source && (
|
||||
<span className={[s.badge, (displayManga.source as any).isNsfw ? s.badgeNsfw : ""].join(" ").trim()}>
|
||||
{displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""}
|
||||
</span>
|
||||
)}
|
||||
{inLibrary && <span className={[s.badge, s.badgeAccent].join(" ")}>In Library</span>}
|
||||
{!loadingChapters && unreadCount > 0 && (
|
||||
<span className={[s.badge, s.badgeUnread].join(" ")}>{unreadCount} unread</span>
|
||||
)}
|
||||
{!loadingChapters && bookmarkCount > 0 && (
|
||||
<span className={s.badge}>{bookmarkCount} bookmarked</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chapter section — visually separated box ── */}
|
||||
<div className={s.chapterBox}>
|
||||
{loadingChapters ? (
|
||||
<div className={s.chapterLoading}>
|
||||
<CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
<span className={s.chapterLoadingLabel}>Loading chapters…</span>
|
||||
</div>
|
||||
) : totalCount > 0 ? (
|
||||
<>
|
||||
<div className={s.chapterMeta}>
|
||||
<span className={s.chapterLabel}>
|
||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||
{readCount > 0 && ` · ${readCount} read`}
|
||||
{unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`}
|
||||
{downloadedCount > 0 && ` · ${downloadedCount} dl`}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<button className={s.dlAllBtn} onClick={downloadAll} disabled={queueingAll}>
|
||||
{queueingAll && <CircleNotch size={11} weight="light" className="anim-spin" />}
|
||||
{queueingAll ? "Queuing…" : "Download unread"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{readCount > 0 && (
|
||||
<div className={s.progressTrack}>
|
||||
<div className={s.progressFill} style={{ width: `${(readCount / totalCount) * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{continueChapter && (
|
||||
<button className={s.readBtn}
|
||||
onClick={() => { openReader(continueChapter.ch, chapters); close(); }}
|
||||
>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.label}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : !loadingDetail ? (
|
||||
<span className={s.chapterLabel} style={{ color: "var(--text-faint)" }}>
|
||||
No chapters in local library
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── Description — clearly separated from chapter block ── */}
|
||||
{loadingDetail ? (
|
||||
<div className={s.skDesc}>
|
||||
<div className={s.skLine} style={{ width: "100%" }} />
|
||||
<div className={s.skLine} style={{ width: "88%" }} />
|
||||
<div className={s.skLine} style={{ width: "70%" }} />
|
||||
</div>
|
||||
) : displayManga.description ? (
|
||||
<div className={s.descBlock}>
|
||||
<p className={[s.desc, descExpanded ? s.descOpen : ""].join(" ")}>
|
||||
{displayManga.description}
|
||||
</p>
|
||||
{displayManga.description.length > 220 && (
|
||||
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
|
||||
{descExpanded ? "Show less" : "Show more"}
|
||||
<CaretDown size={10} weight="light" style={{
|
||||
transform: descExpanded ? "rotate(180deg)" : "none",
|
||||
transition: "transform 0.15s ease",
|
||||
}} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Genre tags ── */}
|
||||
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
|
||||
<div className={s.genres}>
|
||||
{displayManga.genre.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
className={[s.genreTag, s.genreTagClickable].join(" ")}
|
||||
title={`Browse "${g}"`}
|
||||
onClick={() => {
|
||||
setGenreFilter(g);
|
||||
setNavPage("explore");
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Metadata table ── */}
|
||||
{!loadingDetail && (
|
||||
<div className={s.metaTable}>
|
||||
{displayManga.author && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Author</span>
|
||||
<span className={s.metaVal}>{displayManga.author}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.artist && displayManga.artist !== displayManga.author && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Artist</span>
|
||||
<span className={s.metaVal}>{displayManga.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
{statusLabel && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Status</span>
|
||||
<span className={s.metaVal}>{statusLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.source && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Source</span>
|
||||
<span className={s.metaVal}>{displayManga.source.displayName}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && scanlators.length > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span>
|
||||
<span className={s.metaVal}>{scanlators.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && firstUpload && lastUpload && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Published</span>
|
||||
<span className={s.metaVal}>
|
||||
{firstUpload.getTime() === lastUpload.getTime()
|
||||
? formatDate(firstUpload)
|
||||
: `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && downloadedCount > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Downloaded</span>
|
||||
<span className={s.metaVal}>{downloadedCount} / {totalCount} chapters</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && bookmarkCount > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Bookmarks</span>
|
||||
<span className={s.metaVal}>{bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.realUrl && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Link</span>
|
||||
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" className={s.metaLink}>
|
||||
Open <ArrowSquareOut size={11} weight="light" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
.headerActions { display: flex; gap: var(--sp-1); }
|
||||
.iconBtn {
|
||||
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);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.4; }
|
||||
.iconBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.iconBtnActive:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); filter: brightness(1.1); }
|
||||
|
||||
.externalPanel {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
.externalHeader {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.externalTitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.externalRow {
|
||||
display: flex; gap: var(--sp-2);
|
||||
}
|
||||
.externalInput {
|
||||
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
||||
color: var(--text-primary); font-size: var(--text-sm); outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.externalInput:focus { border-color: var(--border-focus); }
|
||||
.externalInput:disabled { opacity: 0.5; }
|
||||
.externalInputError { border-color: var(--color-error) !important; }
|
||||
.externalError {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--color-error); letter-spacing: var(--tracking-wide);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.installBtn {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 6px 14px; 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), opacity var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.installBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
.installBtnSuccess {
|
||||
background: var(--color-success, #2d6a3f); border-color: var(--color-success, #2d6a3f);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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; }
|
||||
.tab {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px; border-radius: var(--radius-md); border: none;
|
||||
background: none; color: var(--text-muted); cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.tabActive { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||
.searchIcon { 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::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.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: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);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.langTag {
|
||||
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);
|
||||
}
|
||||
.nsfwTag {
|
||||
background: transparent; border: 1px solid var(--color-error);
|
||||
border-radius: var(--radius-sm); padding: 1px 5px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--color-error); letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.updateBadge {
|
||||
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;
|
||||
}
|
||||
.updateBadgeSmall {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--accent-fg); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rowActions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.actionBtn {
|
||||
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);
|
||||
}
|
||||
.actionBtn:hover { filter: brightness(1.1); }
|
||||
.actionBtnDim {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px; border-radius: var(--radius-md);
|
||||
background: none; color: var(--text-faint);
|
||||
border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.actionBtnDim:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||
|
||||
.expandBtn {
|
||||
display: flex; align-items: center; gap: 3px;
|
||||
padding: 4px 6px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.expandBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.expandCount {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.variants {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3));
|
||||
padding-left: var(--sp-3);
|
||||
border-left: 1px solid var(--border-dim);
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
.variantRow {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.variantRow:hover { background: var(--bg-raised); }
|
||||
.variantName {
|
||||
flex: 1; font-size: var(--text-sm); color: var(--text-muted);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.variantVersion {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||
}
|
||||
.variantActions { 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);
|
||||
}
|
||||
|
||||
/* ── Panel shared styles ── */
|
||||
.externalPanel {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
.panelHeader {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.panelTitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.panelError {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--color-error); letter-spacing: var(--tracking-wide);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.externalRow { display: flex; gap: var(--sp-2); }
|
||||
.externalInput {
|
||||
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
||||
color: var(--text-primary); font-size: var(--text-sm); outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.externalInput:focus { border-color: var(--border-focus); }
|
||||
.externalInput:disabled { opacity: 0.5; }
|
||||
.externalInputError { border-color: var(--color-error) !important; }
|
||||
.installBtn {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 6px 14px; 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), opacity var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.installBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
.installBtnSuccess {
|
||||
background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
|
||||
border-color: var(--accent-fg); color: var(--accent-fg);
|
||||
}
|
||||
|
||||
/* ── Repo list ── */
|
||||
.repoLoading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
.repoEmpty {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
padding: var(--sp-1) 2px;
|
||||
}
|
||||
.repoList {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.repoRow {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
}
|
||||
.repoUrl {
|
||||
flex: 1; font-family: var(--font-mono, monospace); font-size: var(--text-2xs);
|
||||
color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.repoRemoveBtn {
|
||||
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);
|
||||
}
|
||||
.repoRemoveBtn:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
||||
.repoRemoveBtn:disabled { opacity: 0.4; }
|
||||
@@ -1,407 +0,0 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION,
|
||||
GET_SETTINGS, SET_EXTENSION_REPOS,
|
||||
} from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Extension } from "../../lib/types";
|
||||
import s from "./ExtensionList.module.css";
|
||||
|
||||
type Filter = "installed" | "available" | "updates" | "all";
|
||||
type Panel = null | "apk" | "repos";
|
||||
|
||||
function baseName(name: string): string {
|
||||
return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim();
|
||||
}
|
||||
|
||||
interface ExtGroup {
|
||||
base: string;
|
||||
primary: Extension;
|
||||
variants: Extension[];
|
||||
}
|
||||
|
||||
export default function ExtensionList() {
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [filter, setFilter] = useState<Filter>("installed");
|
||||
const [search, setSearch] = useState("");
|
||||
const [working, setWorking] = useState<Set<string>>(new Set());
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [panel, setPanel] = useState<Panel>(null);
|
||||
|
||||
// APK install state
|
||||
const [externalUrl, setExternalUrl] = useState("");
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [installSuccess, setInstallSuccess] = useState(false);
|
||||
|
||||
// Repo management state
|
||||
const [repos, setRepos] = useState<string[]>([]);
|
||||
const [reposLoading, setReposLoading] = useState(false);
|
||||
const [newRepoUrl, setNewRepoUrl] = useState("");
|
||||
const [repoError, setRepoError] = useState<string | null>(null);
|
||||
const [savingRepos, setSavingRepos] = useState(false);
|
||||
|
||||
const preferredLang = useStore((s) => s.settings.preferredExtensionLang);
|
||||
|
||||
async function load() {
|
||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
||||
.then((d) => setExtensions(d.extensions.nodes))
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
async function fetchFromRepo() {
|
||||
setRefreshing(true);
|
||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
||||
.then((d) => setExtensions(d.fetchExtensions.extensions))
|
||||
.catch(console.error)
|
||||
.finally(() => setRefreshing(false));
|
||||
}
|
||||
|
||||
async function loadRepos() {
|
||||
setReposLoading(true);
|
||||
try {
|
||||
const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS);
|
||||
setRepos(d.settings.extensionRepos ?? []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setReposLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRepos(updated: string[]) {
|
||||
setSavingRepos(true);
|
||||
try {
|
||||
const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(
|
||||
SET_EXTENSION_REPOS, { repos: updated }
|
||||
);
|
||||
setRepos(d.setSettings.settings.extensionRepos);
|
||||
} catch (e: unknown) {
|
||||
setRepoError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSavingRepos(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addRepo() {
|
||||
const url = newRepoUrl.trim();
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
setRepoError("URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
if (repos.includes(url)) {
|
||||
setRepoError("Repo already added");
|
||||
return;
|
||||
}
|
||||
setRepoError(null);
|
||||
setNewRepoUrl("");
|
||||
saveRepos([...repos, url]);
|
||||
}
|
||||
|
||||
function removeRepo(url: string) {
|
||||
saveRepos(repos.filter((r) => r !== url));
|
||||
}
|
||||
|
||||
const mutate = async (fn: () => Promise<unknown>, pkgName: string) => {
|
||||
setWorking((p) => new Set(p).add(pkgName));
|
||||
await fn().catch(console.error);
|
||||
await load();
|
||||
setWorking((p) => { const n = new Set(p); n.delete(pkgName); return n; });
|
||||
};
|
||||
|
||||
async function installExternal() {
|
||||
const url = externalUrl.trim();
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
setInstallError("URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
if (!url.endsWith(".apk")) {
|
||||
setInstallError("URL must point to an .apk file");
|
||||
return;
|
||||
}
|
||||
setInstalling(true);
|
||||
setInstallError(null);
|
||||
setInstallSuccess(false);
|
||||
try {
|
||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
||||
setInstallSuccess(true);
|
||||
setExternalUrl("");
|
||||
await load();
|
||||
setTimeout(() => {
|
||||
setPanel(null);
|
||||
setInstallSuccess(false);
|
||||
}, 1500);
|
||||
} catch (e: unknown) {
|
||||
setInstallError(e instanceof Error ? e.message : "Install failed");
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openPanel(p: Panel) {
|
||||
if (panel === p) {
|
||||
setPanel(null);
|
||||
return;
|
||||
}
|
||||
setPanel(p);
|
||||
setInstallError(null);
|
||||
setInstallSuccess(false);
|
||||
setExternalUrl("");
|
||||
setRepoError(null);
|
||||
setNewRepoUrl("");
|
||||
if (p === "repos") loadRepos();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchFromRepo().finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = extensions.filter((e) => {
|
||||
const q = search.toLowerCase();
|
||||
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;
|
||||
return matchSearch && matchFilter;
|
||||
});
|
||||
|
||||
const groups = useMemo<ExtGroup[]>(() => {
|
||||
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);
|
||||
}
|
||||
return Array.from(map.entries()).map(([base, all]) => {
|
||||
const primary =
|
||||
all.find((v) => v.lang === preferredLang) ??
|
||||
all.find((v) => v.lang === "en") ??
|
||||
all[0];
|
||||
const variants = all.filter((v) => v.pkgName !== primary.pkgName);
|
||||
return { base, primary, variants };
|
||||
});
|
||||
}, [filtered, preferredLang]);
|
||||
|
||||
const updateCount = extensions.filter((e) => e.hasUpdate).length;
|
||||
|
||||
const FILTERS: { id: Filter; label: string }[] = [
|
||||
{ id: "installed", label: "Installed" },
|
||||
{ id: "available", label: "Available" },
|
||||
{ id: "updates", label: updateCount > 0 ? `Updates (${updateCount})` : "Updates" },
|
||||
{ id: "all", label: "All" },
|
||||
];
|
||||
|
||||
function toggleExpand(base: string) {
|
||||
setExpanded((p) => {
|
||||
const n = new Set(p);
|
||||
n.has(base) ? n.delete(base) : n.add(base);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
function renderActions(ext: Extension) {
|
||||
if (working.has(ext.pkgName))
|
||||
return <CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />;
|
||||
if (ext.hasUpdate) return (
|
||||
<div className={s.rowActions}>
|
||||
<button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, update: true }), ext.pkgName)}>Update</button>
|
||||
<button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>
|
||||
</div>
|
||||
);
|
||||
if (ext.isInstalled)
|
||||
return <button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>;
|
||||
return <button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, install: true }), ext.pkgName)}>Install</button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Extensions</h1>
|
||||
<div className={s.headerActions}>
|
||||
<button
|
||||
className={[s.iconBtn, panel === "repos" ? s.iconBtnActive : ""].join(" ").trim()}
|
||||
onClick={() => openPanel("repos")} title="Manage repos">
|
||||
<GitBranch size={14} weight="light" />
|
||||
</button>
|
||||
<button
|
||||
className={[s.iconBtn, panel === "apk" ? s.iconBtnActive : ""].join(" ").trim()}
|
||||
onClick={() => openPanel("apk")} title="Install from URL">
|
||||
<Plus size={14} weight="light" />
|
||||
</button>
|
||||
<button className={s.iconBtn} onClick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
||||
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── APK install panel ── */}
|
||||
{panel === "apk" && (
|
||||
<div className={s.externalPanel}>
|
||||
<div className={s.panelHeader}>
|
||||
<span className={s.panelTitle}>Install from APK URL</span>
|
||||
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
<div className={s.externalRow}>
|
||||
<input
|
||||
className={[s.externalInput, installError ? s.externalInputError : ""].join(" ").trim()}
|
||||
placeholder="https://example.com/extension.apk"
|
||||
value={externalUrl}
|
||||
onChange={(e) => { setExternalUrl(e.target.value); setInstallError(null); }}
|
||||
onKeyDown={(e) => e.key === "Enter" && !installing && installExternal()}
|
||||
autoFocus
|
||||
disabled={installing}
|
||||
/>
|
||||
<button
|
||||
className={[s.installBtn, installSuccess ? s.installBtnSuccess : ""].join(" ").trim()}
|
||||
onClick={installExternal}
|
||||
disabled={installing || !externalUrl.trim()}
|
||||
>
|
||||
{installing
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: installSuccess
|
||||
? <><Check size={13} weight="bold" /> Done</>
|
||||
: "Install"}
|
||||
</button>
|
||||
</div>
|
||||
{installError && <div className={s.panelError}>{installError}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Repo management panel ── */}
|
||||
{panel === "repos" && (
|
||||
<div className={s.externalPanel}>
|
||||
<div className={s.panelHeader}>
|
||||
<span className={s.panelTitle}>Extension Repositories</span>
|
||||
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{reposLoading ? (
|
||||
<div className={s.repoLoading}>
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{repos.length === 0 ? (
|
||||
<div className={s.repoEmpty}>No repos configured.</div>
|
||||
) : (
|
||||
<div className={s.repoList}>
|
||||
{repos.map((url) => (
|
||||
<div key={url} className={s.repoRow}>
|
||||
<span className={s.repoUrl}>{url}</span>
|
||||
<button
|
||||
className={s.repoRemoveBtn}
|
||||
onClick={() => removeRepo(url)}
|
||||
disabled={savingRepos}
|
||||
title="Remove repo"
|
||||
>
|
||||
{savingRepos
|
||||
? <CircleNotch size={12} weight="light" className="anim-spin" />
|
||||
: <X size={12} weight="bold" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.externalRow} style={{ marginTop: "var(--sp-2)" }}>
|
||||
<input
|
||||
className={[s.externalInput, repoError ? s.externalInputError : ""].join(" ").trim()}
|
||||
placeholder="https://example.com/index.min.json"
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => { setNewRepoUrl(e.target.value); setRepoError(null); }}
|
||||
onKeyDown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
||||
disabled={savingRepos}
|
||||
/>
|
||||
<button
|
||||
className={s.installBtn}
|
||||
onClick={addRepo}
|
||||
disabled={savingRepos || !newRepoUrl.trim()}
|
||||
>
|
||||
{savingRepos
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: "Add"}
|
||||
</button>
|
||||
</div>
|
||||
{repoError && <div className={s.panelError}>{repoError}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.controls}>
|
||||
<div className={s.tabs}>
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.id} onClick={() => setFilter(f.id)}
|
||||
className={[s.tab, filter === f.id ? s.tabActive : ""].join(" ").trim()}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input className={s.search} placeholder="Search"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className={s.empty}>No extensions found.</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{groups.map(({ base, primary, variants }) => {
|
||||
const isExpanded = expanded.has(base);
|
||||
const hasVariants = variants.length > 0;
|
||||
return (
|
||||
<div key={base} className={s.group}>
|
||||
<div className={s.row}>
|
||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} className={s.icon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<div className={s.info}>
|
||||
<span className={s.name}>{base}</span>
|
||||
<span className={s.meta}>
|
||||
<span className={s.langTag}>{primary.lang.toUpperCase()}</span>
|
||||
{" "}v{primary.versionName}
|
||||
</span>
|
||||
</div>
|
||||
{primary.hasUpdate && <span className={s.updateBadge}>Update</span>}
|
||||
{renderActions(primary)}
|
||||
{hasVariants && (
|
||||
<button className={s.expandBtn} onClick={() => toggleExpand(base)}
|
||||
title={`${variants.length + 1} languages`}>
|
||||
{isExpanded ? <CaretDown size={12} weight="light" /> : <CaretRight size={12} weight="light" />}
|
||||
<span className={s.expandCount}>{variants.length + 1}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && hasVariants && (
|
||||
<div className={s.variants}>
|
||||
{variants.map((v) => (
|
||||
<div key={v.pkgName} className={s.variantRow}>
|
||||
<span className={s.langTag}>{v.lang.toUpperCase()}</span>
|
||||
<span className={s.variantName}>{v.name}</span>
|
||||
<span className={s.variantVersion}>v{v.versionName}</span>
|
||||
{v.hasUpdate && <span className={s.updateBadgeSmall}>↑</span>}
|
||||
<div className={s.variantActions}>{renderActions(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: var(--bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
/* GPU layer for main content area */
|
||||
transform: translateZ(0);
|
||||
contain: layout style;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { store } from "../../store/state.svelte";
|
||||
import Sidebar from "./Sidebar.svelte";
|
||||
import Home from "../pages/Home.svelte";
|
||||
import Library from "../pages/Library.svelte";
|
||||
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
||||
import History from "../pages/History.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 Extensions from "../pages/Extensions.svelte";
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
{#if store.activeManga}
|
||||
<SeriesDetail />
|
||||
{:else if store.navPage === "home"}
|
||||
<Home />
|
||||
{:else if store.navPage === "library"}
|
||||
<Library />
|
||||
{:else if store.navPage === "search"}
|
||||
<Search />
|
||||
{:else if store.navPage === "history"}
|
||||
<History />
|
||||
{: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"}
|
||||
<Downloads />
|
||||
{:else if store.navPage === "extensions"}
|
||||
<Extensions />
|
||||
{:else}
|
||||
<Home />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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; }
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useStore } from "../../store";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Library from "../pages/Library";
|
||||
import SeriesDetail from "../pages/SeriesDetail";
|
||||
import History from "../pages/History";
|
||||
import Search from "../pages/Search";
|
||||
import Explore from "../explore/Explore";
|
||||
import DownloadQueue from "../downloads/DownloadQueue";
|
||||
import ExtensionList from "../extensions/ExtensionList";
|
||||
import s from "./Layout.module.css";
|
||||
|
||||
export default function Layout() {
|
||||
const navPage = useStore((s) => s.navPage);
|
||||
const activeManga = useStore((s) => s.activeManga);
|
||||
|
||||
function renderContent() {
|
||||
if (activeManga) return <SeriesDetail />;
|
||||
switch (navPage) {
|
||||
case "library": return <Library />;
|
||||
case "search": return <Search />;
|
||||
case "history": return <History />;
|
||||
case "sources": return <Explore />;
|
||||
case "explore": return <Explore />;
|
||||
case "downloads": return <DownloadQueue />;
|
||||
case "extensions": return <ExtensionList />;
|
||||
default: return <Library />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<Sidebar />
|
||||
<main className={s.main}>{renderContent()}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
||||
import { thumbUrl } from "../../lib/client";
|
||||
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
|
||||
import type { HistoryEntry } from "../../store/state.svelte";
|
||||
|
||||
let search = $state("");
|
||||
let confirmClear = $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";
|
||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatReadTime(m: number): string {
|
||||
if (m < 1) return "< 1 min";
|
||||
if (m < 60) return `${m} min`;
|
||||
const h = Math.floor(m / 60), r = m % 60;
|
||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||
}
|
||||
|
||||
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..filter((e) =>
|
||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
||||
e.chapterName.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: store.);
|
||||
|
||||
const sessions = $derived(buildSessions(filtered));
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
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 }));
|
||||
});
|
||||
|
||||
function resume(session: Session) {
|
||||
const ch = store..find((c) => c.id === session.latestChapterId);
|
||||
if (ch && store..length > 0) openReader(ch, );
|
||||
else setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (!confirmClear) { confirmClear = true; setTimeout(() => confirmClear = false, 3000); return; }
|
||||
clearHistory(); confirmClear = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
|
||||
<div class="page-header">
|
||||
<span class="heading">History</span>
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search store.…" bind:value={search} />
|
||||
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
|
||||
</div>
|
||||
{#if store..length > 0}
|
||||
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
|
||||
title={confirmClear ? "Click again to confirm" : "Clear store. feed"}>
|
||||
<Trash size={14} weight="light" />
|
||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if store..totalChaptersRead > 0}
|
||||
<div class="stats-bar">
|
||||
<div class="stat-group">
|
||||
<Fire size={13} weight="fill" class="stat-fire" />
|
||||
<span class="stat-val accent">{store..currentStreakDays}</span>
|
||||
<span class="stat-label">day streak</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
|
||||
<span class="stat-val">{store..totalChaptersRead}</span>
|
||||
<span class="stat-label">chapters</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<Clock size={13} weight="light" class="stat-icon-neutral" />
|
||||
<span class="stat-val">{formatReadTime(store..totalMinutesRead)}</span>
|
||||
<span class="stat-label">read time</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
|
||||
<span class="stat-val">{store..totalMangaRead}</span>
|
||||
<span class="stat-label">series</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<span class="stat-val muted">{store..longestStreakDays}d</span>
|
||||
<span class="stat-label">best streak</span>
|
||||
</div>
|
||||
<span class="stats-note">Stats are preserved when you clear the feed</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if store..length === 0}
|
||||
<div class="empty">
|
||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
||||
<p class="empty-text">No reading store.</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="timeline">
|
||||
{#each groups as { label, items }}
|
||||
<div class="day-group">
|
||||
<div class="day-label-row">
|
||||
<span class="day-label">{label}</span>
|
||||
<div class="day-line"></div>
|
||||
</div>
|
||||
<div class="session-list">
|
||||
{#each items as session (session.latestChapterId)}
|
||||
<button class="session-row" onclick={() => resume(session)}>
|
||||
<div class="thumb-wrap">
|
||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
|
||||
{#if session.chapterCount > 1}
|
||||
<span class="session-count">{session.chapterCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="session-info">
|
||||
<span class="session-title">{session.mangaTitle}</span>
|
||||
<span class="session-chapter">
|
||||
{#if session.chapterCount > 1}
|
||||
{session.firstChapterName}
|
||||
<span class="ch-arrow">→</span>
|
||||
{session.latestChapterName}
|
||||
{:else}
|
||||
{session.latestChapterName}
|
||||
{#if session.latestPageNumber > 1}
|
||||
<span class="ch-page">p.{session.latestPageNumber}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span class="session-time">{timeAgo(session.readAt)}</span>
|
||||
<div class="play-pill">
|
||||
<Play size={10} weight="fill" /> Resume
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
.page-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); 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 26px 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; 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:hover { color: var(--text-muted); }
|
||||
|
||||
.clear-btn {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
height: 28px; padding: 0 var(--sp-2); border-radius: var(--radius-md);
|
||||
color: var(--text-faint); background: none; border: 1px solid transparent;
|
||||
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
||||
.clear-label { font-size: var(--text-2xs); }
|
||||
|
||||
.stats-bar {
|
||||
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
|
||||
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised); flex-shrink: 0;
|
||||
}
|
||||
.stat-group { display: flex; align-items: center; gap: 5px; }
|
||||
.stat-sep { width: 1px; height: 14px; background: var(--border-dim); flex-shrink: 0; }
|
||||
:global(.stat-fire) { color: #f97316; }
|
||||
:global(.stat-icon-neutral) { color: var(--text-faint); }
|
||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.stat-val.accent { color: var(--accent-fg); }
|
||||
.stat-val.muted { color: var(--text-faint); }
|
||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.stats-note { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.5; letter-spacing: var(--tracking-wide); font-style: italic; }
|
||||
|
||||
.timeline { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
||||
|
||||
.day-group { margin-bottom: var(--sp-5); }
|
||||
.day-label-row { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); }
|
||||
.day-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; white-space: nowrap; flex-shrink: 0; }
|
||||
.day-line { flex: 1; height: 1px; background: var(--border-dim); }
|
||||
|
||||
.session-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.session-row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md);
|
||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.session-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.session-row:hover .play-pill { opacity: 1; transform: translateX(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); }
|
||||
.session-count {
|
||||
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;
|
||||
}
|
||||
|
||||
.session-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.session-chapter { font-size: var(--text-xs); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ch-arrow { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
||||
.ch-page { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
|
||||
.session-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; }
|
||||
.play-pill {
|
||||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
||||
padding: 3px 8px; border-radius: var(--radius-full);
|
||||
opacity: 0; transform: translateX(4px);
|
||||
transition: opacity var(--t-base), transform 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,107 +0,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;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--sp-3);
|
||||
overflow: visible;
|
||||
/* Explicit reset — prevents browser from injecting a default button background */
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: opacity var(--t-base), transform var(--t-base);
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||
.logo:active { transform: scale(0.92); }
|
||||
/* Kill the focus ring that can render as a coloured glow on some GTK themes */
|
||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.logoIcon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: var(--accent);
|
||||
mask-image: url("../../assets/moku-icon.svg");
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
-webkit-mask-image: url("../../assets/moku-icon.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);
|
||||
}
|
||||
|
||||
.tab {
|
||||
width: 36px; height: 36px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
/* Explicit resets — the green overlay was browser default button styles bleeding through */
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-md); }
|
||||
|
||||
.tabActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
/* Prevent hover state from overriding active colour */
|
||||
.tabActive: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);
|
||||
}
|
||||
|
||||
.settingsBtn {
|
||||
width: 36px; height: 36px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
/* Same explicit resets */
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
||||
}
|
||||
.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||
.settingsBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix } from "phosphor-svelte";
|
||||
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
||||
import type { NavPage } from "../../store/state.svelte";
|
||||
|
||||
const TABS: { id: NavPage; label: string; icon: any }[] = [
|
||||
{ id: "home", label: "Home", icon: House },
|
||||
{ id: "library", label: "Library", icon: Books },
|
||||
{ id: "search", label: "Search", icon: MagnifyingGlass },
|
||||
{ id: "history", label: "History", icon: ClockCounterClockwise },
|
||||
{ id: "explore", label: "Discover", icon: Compass },
|
||||
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
||||
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
||||
];
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
store.navPage = id;
|
||||
store.activeManga = null;
|
||||
store.genreFilter = "";
|
||||
if (id !== "explore") store.activeSource = null;
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
store.navPage = "home";
|
||||
store.activeSource = null;
|
||||
store.activeManga = null;
|
||||
store.libraryFilter = "library";
|
||||
store.genreFilter = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="root">
|
||||
<button class="logo" onclick={goHome} title="Home" aria-label="Go to Home">
|
||||
<div class="logo-icon"></div>
|
||||
</button>
|
||||
<nav class="nav">
|
||||
{#each TABS as tab}
|
||||
<button class="tab" class:active={store.navPage === tab.id}
|
||||
title={tab.label} onclick={() => navigate(tab.id)}>
|
||||
<tab.icon size={18} weight="light" />
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="bottom">
|
||||
<button class="settings-btn" onclick={() => store.settingsOpen = true} title="Settings">
|
||||
<GearSix size={18} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<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; }
|
||||
.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:hover { opacity: 0.8; transform: scale(0.96); }
|
||||
.logo:active { transform: scale(0.92); }
|
||||
.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; }
|
||||
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); }
|
||||
.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); }
|
||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.tab.active { 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); }
|
||||
.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:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
</style>
|
||||
@@ -1,62 +0,0 @@
|
||||
import {
|
||||
Books, DownloadSimple, PuzzlePiece, Compass,
|
||||
GearSix, ClockCounterClockwise, MagnifyingGlass,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useStore, type NavPage } from "../../store";
|
||||
import s from "./Sidebar.module.css";
|
||||
|
||||
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||
{ id: "explore", icon: <Compass size={18} weight="light" />, label: "Explore" },
|
||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const navPage = useStore((state) => state.navPage);
|
||||
const setNavPage = useStore((state) => state.setNavPage);
|
||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||
const openSettings = useStore((state) => state.openSettings);
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
setNavPage(id);
|
||||
setActiveManga(null);
|
||||
setGenreFilter("");
|
||||
if (id !== "explore") setActiveSource(null);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
setNavPage("library");
|
||||
setActiveSource(null);
|
||||
setActiveManga(null);
|
||||
setLibraryFilter("library");
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={s.root}>
|
||||
{/* Logo click → back to library root */}
|
||||
<button className={s.logo} onClick={goHome} title="Go to Library" aria-label="Go to Library">
|
||||
<div className={s.logoIcon} />
|
||||
</button>
|
||||
<nav className={s.nav}>
|
||||
{TABS.map((tab) => (
|
||||
<button key={tab.id} title={tab.label}
|
||||
onClick={() => navigate(tab.id)}
|
||||
className={[s.tab, navPage === tab.id ? s.tabActive : ""].join(" ")}>
|
||||
{tab.icon}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={s.bottom}>
|
||||
<button className={s.settingsBtn} onClick={openSettings} title="Settings">
|
||||
<GearSix size={18} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
<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,518 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import logoUrl from "../../assets/moku-icon.svg";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
export type SplashMode = "loading" | "idle";
|
||||
export const EXIT_MS = 320;
|
||||
|
||||
interface Props {
|
||||
mode: SplashMode;
|
||||
ringFull?: boolean;
|
||||
failed?: boolean;
|
||||
showCards?: boolean;
|
||||
showFps?: boolean;
|
||||
onReady?: () => void;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
// ── Hash ──────────────────────────────────────────────────────────────────────
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Card definition ───────────────────────────────────────────────────────────
|
||||
interface CardDef {
|
||||
layer: 0 | 1 | 2;
|
||||
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;
|
||||
const COLS = 14;
|
||||
|
||||
function buildCards(vw: number, vh: number): { cards: CardDef[]; trigs: CardTrig[] } {
|
||||
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 maxNudge = (laneW - w) / 2 - 2;
|
||||
const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge);
|
||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
||||
const travel = vh + h + BUF;
|
||||
cards.push({
|
||||
layer: layer as 0 | 1 | 2,
|
||||
cx, 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 };
|
||||
}
|
||||
|
||||
// ── Rounded rect ──────────────────────────────────────────────────────────────
|
||||
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();
|
||||
}
|
||||
|
||||
// ── Stamp builder ─────────────────────────────────────────────────────────────
|
||||
const STAMP_PAD = 6;
|
||||
|
||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||
const logW = Math.ceil(c.w + STAMP_PAD * 2);
|
||||
const logH = Math.ceil(c.h + STAMP_PAD * 2);
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = Math.round(logW * dpr);
|
||||
oc.height = Math.round(logH * dpr);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const x0 = STAMP_PAD;
|
||||
const 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;
|
||||
}
|
||||
|
||||
// ── Vignette builder ──────────────────────────────────────────────────────────
|
||||
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.15, "rgba(0,0,0,0)");
|
||||
g.addColorStop(1, "rgba(0,0,0,0.82)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, vw, vh);
|
||||
return oc;
|
||||
}
|
||||
|
||||
// ── Draw frame ────────────────────────────────────────────────────────────────
|
||||
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 cosDelta = Math.cos(delta);
|
||||
const sinDelta = Math.sin(delta);
|
||||
const cos = tg.cosA * cosDelta - tg.sinA * sinDelta;
|
||||
const sin = tg.sinA * cosDelta + tg.cosA * sinDelta;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.setTransform(
|
||||
cos * dpr, sin * dpr,
|
||||
-sin * dpr, cos * dpr,
|
||||
c.cx * dpr, cy * dpr,
|
||||
);
|
||||
// Draw stamp at its natural logical size.
|
||||
// The stamp was baked at (logical * dpr) physical pixels.
|
||||
// setTransform already applied dpr scaling, so drawing at logical size
|
||||
// means the stamp maps 1:1 to physical pixels — zero resampling, zero blur.
|
||||
const sw = stamps[i].width / dpr;
|
||||
const 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);
|
||||
}
|
||||
|
||||
// ── Ring ──────────────────────────────────────────────────────────────────────
|
||||
function Ring({ progress }: { progress: number }) {
|
||||
const r = 44, sw = 2, pad = 8;
|
||||
const size = (r + pad) * 2, c = r + pad;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const arc = circ * Math.min(Math.max(progress, 0.025), 0.999);
|
||||
return (
|
||||
<svg width={size} height={size} style={{
|
||||
position: "absolute", pointerEvents: "none",
|
||||
top: -((size - 80) / 2), left: -((size - 80) / 2),
|
||||
}}>
|
||||
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={sw} />
|
||||
<circle cx={c} cy={c} r={r} fill="none" stroke="#4ade80" strokeWidth={sw}
|
||||
strokeLinecap="round" strokeDasharray={`${arc} ${circ}`}
|
||||
transform={`rotate(-90 ${c} ${c})`}
|
||||
style={{ transition: "stroke-dasharray 0.55s cubic-bezier(0.4,0,0.2,1)" }} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FPS counter ───────────────────────────────────────────────────────────────
|
||||
function FpsCounter() {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const times = useRef<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
function tick(now: number) {
|
||||
const arr = times.current;
|
||||
arr.push(now);
|
||||
if (arr.length > 60) arr.shift();
|
||||
if (arr.length > 1 && divRef.current) {
|
||||
const fps = Math.round((arr.length - 1) / ((arr[arr.length - 1] - arr[0]) / 1000));
|
||||
divRef.current.textContent = `${fps} fps`;
|
||||
divRef.current.style.color = fps >= 55 ? "#4ade80" : fps >= 30 ? "#facc15" : "#f87171";
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={divRef} style={{
|
||||
position: "fixed", top: 10, right: 14, zIndex: 10001,
|
||||
fontFamily: "var(--font-mono, 'Courier New', monospace)",
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: "0.08em",
|
||||
color: "#4ade80",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
borderRadius: 4, padding: "2px 7px",
|
||||
userSelect: "none", pointerEvents: "none",
|
||||
}}>-- fps</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── CardCanvas ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Strategy: best of both worlds.
|
||||
//
|
||||
// LAYOUT → logical pixels (window.innerWidth/Height or Tauri innerSize/scale)
|
||||
// Cards fill the actual window shape correctly at any size.
|
||||
//
|
||||
// QUALITY → physical pixels (Tauri innerSize + scaleFactor)
|
||||
// Canvas buffer = physical pixels, stamps baked at the true OS DPR.
|
||||
// No WebKitGTK lies, no late compositor hints, always pixel-perfect.
|
||||
//
|
||||
// On every resize both are re-derived together so fullscreen, half-split,
|
||||
// monitor switch — all produce crisp, correctly-proportioned cards.
|
||||
//
|
||||
function CardCanvas({ showFps }: { showFps: boolean }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false });
|
||||
if (!ctx) return;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
|
||||
// ── Live render state ────────────────────────────────────────────────────
|
||||
// The frame loop only ever reads from `live`. syncSize builds a complete
|
||||
// replacement object off-thread then swaps it in one atomic assignment —
|
||||
// no frame ever sees a half-rebuilt state.
|
||||
interface RenderState {
|
||||
cards: ReturnType<typeof buildCards>["cards"];
|
||||
trigs: ReturnType<typeof buildCards>["trigs"];
|
||||
stamps: HTMLCanvasElement[];
|
||||
vignette: HTMLCanvasElement;
|
||||
CW: number; CH: number; scale: number;
|
||||
}
|
||||
let live: RenderState | null = null;
|
||||
|
||||
// Track what we last built so we skip no-op resize events.
|
||||
let lastLogW = 0, lastLogH = 0, lastScale = 0;
|
||||
// Debounce: if a new resize arrives while one is in-flight, we only
|
||||
// want the most recent result. A simple generation counter handles this.
|
||||
let buildGen = 0;
|
||||
|
||||
async function syncSize() {
|
||||
const gen = ++buildGen;
|
||||
|
||||
const [phys, scale] = await Promise.all([
|
||||
win.innerSize(),
|
||||
win.scaleFactor(),
|
||||
]);
|
||||
|
||||
// Another resize fired while we were awaiting — our result is stale.
|
||||
if (gen !== buildGen) return;
|
||||
|
||||
const physW = phys.width;
|
||||
const physH = phys.height;
|
||||
const logW = physW / scale;
|
||||
const logH = physH / scale;
|
||||
|
||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
||||
|
||||
// Build everything into a local staging object — nothing visible changes yet.
|
||||
const built = buildCards(logW, logH);
|
||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
||||
const vig = buildVignette(logW, logH, scale);
|
||||
|
||||
// One atomic swap — the frame loop immediately sees the complete new state.
|
||||
// Canvas dimensions are updated here too so they're always in sync with
|
||||
// the render state that uses them.
|
||||
canvas!.width = physW;
|
||||
canvas!.height = physH;
|
||||
live = {
|
||||
cards: built.cards, trigs: built.trigs,
|
||||
stamps, vignette: vig,
|
||||
CW: physW, CH: physH, scale,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[SplashScreen] syncSize: logical ${Math.round(logW)}×${Math.round(logH)}`,
|
||||
`physical ${physW}×${physH} @${scale.toFixed(3)}×`,
|
||||
);
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => syncSize());
|
||||
ro.observe(canvas);
|
||||
syncSize();
|
||||
|
||||
let raf = 0, t0 = -1;
|
||||
function frame(now: number) {
|
||||
raf = requestAnimationFrame(frame);
|
||||
if (!live) return;
|
||||
if (t0 < 0) t0 = 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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas ref={ref} style={{
|
||||
position: "absolute", inset: 0, pointerEvents: "none",
|
||||
width: "100%", height: "100%",
|
||||
}} />
|
||||
{showFps && <FpsCounter />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Static CSS ────────────────────────────────────────────────────────────────
|
||||
const STATIC_CSS = `
|
||||
@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} }
|
||||
`;
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
export default function SplashScreen({
|
||||
mode, ringFull = false, failed = false,
|
||||
showCards = true, showFps = false,
|
||||
onReady, onRetry, onDismiss,
|
||||
}: Props) {
|
||||
const [dots, setDots] = useState("");
|
||||
const [ringProg, setRingProg] = useState(0.025);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const exitLock = useRef(false);
|
||||
|
||||
function triggerExit(cb?: () => void) {
|
||||
if (exitLock.current) return;
|
||||
exitLock.current = true;
|
||||
setExiting(true);
|
||||
setTimeout(() => cb?.(), EXIT_MS);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!ringFull) return;
|
||||
setRingProg(1);
|
||||
const t = setTimeout(() => triggerExit(onReady), 650);
|
||||
return () => clearTimeout(t);
|
||||
}, [ringFull]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setDots(d => d.length >= 3 ? "" : d + "."), 420);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "idle" || !onDismiss) return;
|
||||
function handler() { triggerExit(onDismiss); }
|
||||
window.addEventListener("keydown", handler, { once: true });
|
||||
window.addEventListener("mousedown", handler, { once: true });
|
||||
window.addEventListener("touchstart", handler, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handler);
|
||||
window.removeEventListener("mousedown", handler);
|
||||
window.removeEventListener("touchstart", handler);
|
||||
};
|
||||
}, [mode, onDismiss]);
|
||||
|
||||
const isIdle = mode === "idle";
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "fixed", inset: 0, zIndex: 9999,
|
||||
background: "var(--bg-base)", overflow: "hidden",
|
||||
display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
cursor: isIdle ? "pointer" : "default",
|
||||
animation: exiting
|
||||
? `spOut ${EXIT_MS}ms cubic-bezier(0.4,0,1,1) both`
|
||||
: "spIn 0.35s cubic-bezier(0,0,0.2,1) both",
|
||||
}}>
|
||||
<style>{STATIC_CSS}</style>
|
||||
|
||||
{showCards && <CardCanvas showFps={showFps} />}
|
||||
|
||||
{isIdle ? (
|
||||
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<div style={{ position: "relative", width: 128, height: 128, marginBottom: 32 }}>
|
||||
<div style={{
|
||||
position: "absolute", inset: -20, borderRadius: "50%",
|
||||
background: "radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%)",
|
||||
animation: "logoBreathe 4s ease-in-out infinite",
|
||||
}} />
|
||||
<img src={logoUrl} alt="Moku" style={{
|
||||
width: 128, height: 128, borderRadius: 28,
|
||||
display: "block", position: "relative",
|
||||
animation: "logoBreathe 4s ease-in-out infinite",
|
||||
}} />
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)",
|
||||
letterSpacing: "0.22em", textTransform: "uppercase",
|
||||
margin: 0, userSelect: "none",
|
||||
animation: "hintFade 3.5s ease-in-out infinite",
|
||||
}}>press any key to continue</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ position: "relative", width: 80, height: 80, marginBottom: 20, zIndex: 1 }}>
|
||||
{!failed && <Ring progress={ringProg} />}
|
||||
<img src={logoUrl} alt="Moku"
|
||||
style={{ width: 80, height: 80, borderRadius: 18, display: "block" }} />
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: "var(--font-ui)", fontSize: 11, fontWeight: 500,
|
||||
letterSpacing: "0.26em", textTransform: "uppercase",
|
||||
color: "var(--text-secondary)", margin: "0 0 8px",
|
||||
zIndex: 1, userSelect: "none",
|
||||
}}>moku</p>
|
||||
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
||||
{failed ? (
|
||||
<>
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 11, color: "var(--color-error)", letterSpacing: "0.1em", margin: 0 }}>
|
||||
Could not reach Suwayomi
|
||||
</p>
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.05em", margin: 0, textAlign: "center", maxWidth: 240, lineHeight: "1.6" }}>
|
||||
Make sure tachidesk-server is on your PATH
|
||||
</p>
|
||||
<button onClick={onRetry} style={{
|
||||
marginTop: 4, padding: "5px 16px", borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--border-dim)", background: "var(--bg-raised)",
|
||||
color: "var(--text-muted)", cursor: "pointer",
|
||||
fontFamily: "var(--font-ui)", fontSize: 11, letterSpacing: "0.08em",
|
||||
}}>Retry</button>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.12em", margin: 0, minWidth: 160, textAlign: "center" }}>
|
||||
{ringFull ? "Ready" : `Initializing server${dots}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
.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;
|
||||
/* Drag region covers the whole bar */
|
||||
-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;
|
||||
/* Controls must NOT be draggable */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn {
|
||||
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);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.btnClose:hover {
|
||||
color: #fff;
|
||||
background: #c0392b;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<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,46 +0,0 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import s from "./TitleBar.module.css";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
|
||||
export default function TitleBar() {
|
||||
return (
|
||||
<div className={s.bar} data-tauri-drag-region>
|
||||
<span className={s.title} data-tauri-drag-region>Moku</span>
|
||||
<div className={s.controls}>
|
||||
<button
|
||||
className={s.btn}
|
||||
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" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={s.btn}
|
||||
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" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={[s.btn, s.btnClose].join(" ")}
|
||||
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" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
.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;
|
||||
animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Kind variants */
|
||||
.toast_success { border-color: var(--accent-dim); }
|
||||
.toast_success .toastIcon { color: var(--accent-fg); }
|
||||
|
||||
.toast_error { border-color: var(--color-error); }
|
||||
.toast_error .toastIcon { color: var(--color-error); }
|
||||
|
||||
.toast_download .toastIcon { color: var(--accent-fg); }
|
||||
.toast_info .toastIcon { color: var(--text-muted); }
|
||||
|
||||
.toastIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.toastBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toastTitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toastSub {
|
||||
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;
|
||||
}
|
||||
|
||||
.toastClose {
|
||||
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);
|
||||
}
|
||||
.toastClose:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
@@ -0,0 +1,91 @@
|
||||
<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,69 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CheckCircle, X, WarningCircle, Info, DownloadSimple } from "@phosphor-icons/react";
|
||||
import { useStore } from "../../store";
|
||||
import s from "./Toaster.module.css";
|
||||
|
||||
export type ToastKind = "success" | "error" | "info" | "download";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
kind: ToastKind;
|
||||
title: string;
|
||||
body?: string;
|
||||
duration?: number; // ms, 0 = persistent
|
||||
}
|
||||
|
||||
// ── icons per kind ──────────────────────────────────────────────────────────
|
||||
|
||||
function ToastIcon({ kind }: { kind: ToastKind }) {
|
||||
const size = 15;
|
||||
const w = "light" as const;
|
||||
if (kind === "success") return <CheckCircle size={size} weight={w} />;
|
||||
if (kind === "error") return <WarningCircle size={size} weight={w} />;
|
||||
if (kind === "download") return <DownloadSimple size={size} weight={w} />;
|
||||
return <Info size={size} weight={w} />;
|
||||
}
|
||||
|
||||
// ── individual toast ─────────────────────────────────────────────────────────
|
||||
|
||||
function ToastItem({ toast }: { toast: Toast }) {
|
||||
const dismissToast = useStore((s) => s.dismissToast);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const duration = toast.duration ?? 3500;
|
||||
|
||||
useEffect(() => {
|
||||
if (duration === 0) return;
|
||||
timerRef.current = setTimeout(() => dismissToast(toast.id), duration);
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}, [toast.id, duration]);
|
||||
|
||||
return (
|
||||
<div className={[s.toast, s[`toast_${toast.kind}`]].join(" ")} role="alert">
|
||||
<span className={s.toastIcon}><ToastIcon kind={toast.kind} /></span>
|
||||
<div className={s.toastBody}>
|
||||
<p className={s.toastTitle}>{toast.title}</p>
|
||||
{toast.body && <p className={s.toastSub}>{toast.body}</p>}
|
||||
</div>
|
||||
<button className={s.toastClose} onClick={() => dismissToast(toast.id)} title="Dismiss">
|
||||
<X size={12} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── toaster container ────────────────────────────────────────────────────────
|
||||
|
||||
export default function Toaster() {
|
||||
const toasts = useStore((s) => s.toasts);
|
||||
|
||||
if (!toasts.length) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className={s.toaster} aria-live="polite">
|
||||
{toasts.map((t) => <ToastItem key={t.id} toast={t} />)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
<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>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||
import { store, setActiveDownloads } from "../../store/state.svelte";
|
||||
import type { DownloadStatus } from "../../lib/types";
|
||||
|
||||
let status: DownloadStatus | null = $state(null);
|
||||
let loading = $state(true);
|
||||
let togglingPlay = $state(false);
|
||||
let clearing = $state(false);
|
||||
let dequeueing = $state(new Set<number>());
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
function applyStatus(ds: DownloadStatus) {
|
||||
status = ds;
|
||||
setActiveDownloads(ds.queue.map((item) => ({
|
||||
chapterId: item.chapter.id,
|
||||
mangaId: item.chapter.mangaId,
|
||||
progress: item.progress,
|
||||
})));
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then((d) => applyStatus(d.downloadStatus))
|
||||
.catch(console.error)
|
||||
.finally(() => loading = false);
|
||||
}
|
||||
|
||||
$effect(() => { poll(); interval = setInterval(poll, 2000); return () => clearInterval(interval); });
|
||||
|
||||
async function togglePlay() {
|
||||
if (togglingPlay) return;
|
||||
togglingPlay = true;
|
||||
const wasRunning = status?.state === "STARTED";
|
||||
if (status) status = { ...status, state: wasRunning ? "STOPPED" : "STARTED" };
|
||||
try {
|
||||
if (wasRunning) {
|
||||
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
||||
applyStatus(d.stopDownloader.downloadStatus);
|
||||
} else {
|
||||
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
||||
applyStatus(d.startDownloader.downloadStatus);
|
||||
}
|
||||
} catch (e) { console.error(e); poll(); }
|
||||
finally { togglingPlay = false; }
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
if (clearing) return;
|
||||
clearing = true;
|
||||
if (status) status = { ...status, queue: [] };
|
||||
setActiveDownloads([]);
|
||||
try {
|
||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||
applyStatus(d.clearDownloader.downloadStatus);
|
||||
} catch (e) { console.error(e); poll(); }
|
||||
finally { clearing = false; }
|
||||
}
|
||||
|
||||
async function dequeue(chapterId: number) {
|
||||
if (dequeueing.has(chapterId)) return;
|
||||
dequeueing = new Set(dequeueing).add(chapterId);
|
||||
if (status) status = { ...status, queue: status.queue.filter((i) => i.chapter.id !== chapterId) };
|
||||
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); poll(); }
|
||||
catch (e) { console.error(e); poll(); }
|
||||
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
|
||||
}
|
||||
let queue = $derived(status?.queue ?? []);
|
||||
const isRunning = $derived(status?.state === "STARTED");
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Downloads</h1>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
|
||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
||||
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||
{:else if isRunning}<Pause size={14} weight="fill" />
|
||||
{:else}<Play size={14} weight="fill" />{/if}
|
||||
</button>
|
||||
<button class="icon-btn" class:loading={clearing} onclick={clear}
|
||||
disabled={clearing || queue.length === 0} title="Clear queue">
|
||||
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||
{:else}<Trash size={14} weight="regular" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-dot" class:active={isRunning}></div>
|
||||
<span class="status-text">
|
||||
{togglingPlay ? (isRunning ? "Pausing…" : "Starting…") : isRunning ? "Downloading" : "Paused"}
|
||||
</span>
|
||||
<span class="status-count">{queue.length} queued</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else if queue.length === 0}
|
||||
<div class="empty">Queue is empty.</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each queue as item, i (item.chapter.id)}
|
||||
{@const isActive = i === 0 && isRunning}
|
||||
{@const pages = item.chapter.pageCount ?? 0}
|
||||
{@const done = Math.round(item.progress * pages)}
|
||||
{@const manga = item.chapter.manga}
|
||||
{@const isRemoving = dequeueing.has(item.chapter.id)}
|
||||
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
|
||||
{#if manga?.thumbnailUrl}
|
||||
<div class="thumb">
|
||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga?.title} class="thumb-img" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="info">
|
||||
{#if manga?.title}<span class="manga-title">{manga.title}</span>{/if}
|
||||
<span class="chapter-name">{item.chapter.name}</span>
|
||||
{#if pages > 0}
|
||||
<span class="pages-label">{isActive ? `${done} / ${pages} pages` : `${pages} pages`}</span>
|
||||
{/if}
|
||||
{#if isActive}
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" style="width:{Math.round(item.progress * 100)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<span class="state-label">{item.state}</span>
|
||||
{#if !isActive}
|
||||
<button class="remove-btn" onclick={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
|
||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { padding: var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-5); }
|
||||
.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); }
|
||||
.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:disabled { opacity: 0.3; cursor: default; }
|
||||
.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-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; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
||||
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.row { 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); transition: border-color var(--t-fast), opacity var(--t-base); }
|
||||
.row.row-active { border-color: var(--accent-dim); }
|
||||
.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-img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.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; }
|
||||
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.progress-wrap { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; margin-top: 4px; }
|
||||
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
||||
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.remove-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); }
|
||||
.remove-btn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.remove-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<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>
|
||||
@@ -0,0 +1,348 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
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 type { Extension } from "../../lib/types";
|
||||
|
||||
type Filter = "installed" | "available" | "updates" | "all";
|
||||
type Panel = null | "apk" | "repos";
|
||||
|
||||
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
|
||||
|
||||
let extensions: Extension[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let filter: Filter = $state("installed");
|
||||
let search = $state("");
|
||||
let working = $state(new Set<string>());
|
||||
let expanded = $state(new Set<string>());
|
||||
let panel: Panel = $state(null);
|
||||
let externalUrl = $state("");
|
||||
let installing = $state(false);
|
||||
let installError: string|null = $state(null);
|
||||
let installSuccess = $state(false);
|
||||
let repos: string[] = $state([]);
|
||||
let reposLoading = $state(false);
|
||||
let newRepoUrl = $state("");
|
||||
let repoError: string|null = $state(null);
|
||||
let savingRepos = $state(false);
|
||||
|
||||
async function load() {
|
||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
||||
.then((d) => extensions = d.extensions.nodes).catch(console.error);
|
||||
}
|
||||
|
||||
async function fetchFromRepo() {
|
||||
refreshing = true;
|
||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
||||
.then((d) => extensions = d.fetchExtensions.extensions).catch(console.error)
|
||||
.finally(() => refreshing = false);
|
||||
}
|
||||
|
||||
async function loadRepos() {
|
||||
reposLoading = true;
|
||||
try { const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS); repos = d.settings.extensionRepos ?? []; }
|
||||
catch (e) { console.error(e); } finally { reposLoading = false; }
|
||||
}
|
||||
|
||||
async function saveRepos(updated: string[]) {
|
||||
savingRepos = true;
|
||||
try { const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(SET_EXTENSION_REPOS, { repos: updated }); repos = d.setSettings.settings.extensionRepos; }
|
||||
catch (e: any) { repoError = e instanceof Error ? e.message : "Failed to save"; } finally { savingRepos = false; }
|
||||
}
|
||||
|
||||
function addRepo() {
|
||||
const url = newRepoUrl.trim();
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) { repoError = "URL must start with http:// or https://"; return; }
|
||||
if (repos.includes(url)) { repoError = "Repo already added"; return; }
|
||||
repoError = null; newRepoUrl = "";
|
||||
saveRepos([...repos, url]);
|
||||
}
|
||||
|
||||
function removeRepo(url: string) { saveRepos(repos.filter((r) => r !== url)); }
|
||||
|
||||
async function mutate(fn: () => Promise<unknown>, pkgName: string) {
|
||||
working = new Set(working).add(pkgName);
|
||||
await fn().catch(console.error);
|
||||
await load();
|
||||
working.delete(pkgName); working = new Set(working);
|
||||
}
|
||||
|
||||
async function installExternal() {
|
||||
const url = externalUrl.trim();
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) { installError = "URL must start with http:// or https://"; return; }
|
||||
if (!url.endsWith(".apk")) { installError = "URL must point to an .apk file"; return; }
|
||||
installing = true; installError = null; installSuccess = false;
|
||||
try {
|
||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
||||
installSuccess = true; externalUrl = "";
|
||||
await load();
|
||||
setTimeout(() => { panel = null; installSuccess = false; }, 1500);
|
||||
} catch (e: any) { installError = e instanceof Error ? e.message : "Install failed"; }
|
||||
finally { installing = false; }
|
||||
}
|
||||
|
||||
function openPanel(p: Panel) {
|
||||
panel = panel === p ? null : p;
|
||||
installError = null; installSuccess = false; externalUrl = "";
|
||||
repoError = null; newRepoUrl = "";
|
||||
if (p === "repos") loadRepos();
|
||||
}
|
||||
|
||||
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); });
|
||||
|
||||
const filtered = $derived(extensions.filter((e) => {
|
||||
const q = search.toLowerCase();
|
||||
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;
|
||||
return matchSearch && matchFilter;
|
||||
}));
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
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); }
|
||||
const preferredLang = store.settings.preferredExtensionLang;
|
||||
return Array.from(map.entries()).map(([base, all]) => {
|
||||
const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0];
|
||||
return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) };
|
||||
});
|
||||
});
|
||||
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
|
||||
|
||||
const FILTERS: { id: Filter; label: string }[] = [
|
||||
{ id: "installed", label: "Installed" },
|
||||
{ id: "available", label: "Available" },
|
||||
{ id: "updates", label: "Updates" },
|
||||
{ id: "all", label: "All" },
|
||||
];
|
||||
|
||||
function toggleExpand(base: string) {
|
||||
const next = new Set(expanded);
|
||||
next.has(base) ? next.delete(base) : next.add(base);
|
||||
expanded = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Extensions</h1>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
|
||||
<GitBranch size={14} weight="light" />
|
||||
</button>
|
||||
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
|
||||
<Plus size={14} weight="light" />
|
||||
</button>
|
||||
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if panel === "apk"}
|
||||
<div class="ext-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Install from APK URL</span>
|
||||
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
<div class="ext-row">
|
||||
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
|
||||
bind:value={externalUrl} disabled={installing}
|
||||
oninput={() => installError = null}
|
||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} autofocus />
|
||||
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
||||
{:else}Install{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if installError}<div class="panel-error">{installError}</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if panel === "repos"}
|
||||
<div class="ext-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Extension Repositories</span>
|
||||
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
{#if reposLoading}
|
||||
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else}
|
||||
{#if repos.length === 0}
|
||||
<div class="repo-empty">No repos configured.</div>
|
||||
{:else}
|
||||
<div class="repo-list">
|
||||
{#each repos as url}
|
||||
<div class="repo-row">
|
||||
<span class="repo-url">{url}</span>
|
||||
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
||||
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ext-row" style="margin-top:var(--sp-2)">
|
||||
<input class="ext-input" class:error={repoError} placeholder="https://example.com/index.min.json"
|
||||
bind:value={newRepoUrl} disabled={savingRepos}
|
||||
oninput={() => repoError = null}
|
||||
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
|
||||
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
||||
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/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}
|
||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else if groups.length === 0}
|
||||
<div class="empty">No extensions found.</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each groups as { base, primary, variants }}
|
||||
{@const isExpanded = expanded.has(base)}
|
||||
{@const hasVariants = variants.length > 0}
|
||||
<div class="group">
|
||||
<div class="row">
|
||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
<div class="info">
|
||||
<span class="name">{base}</span>
|
||||
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
||||
</div>
|
||||
{#if working.has(primary.pkgName)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if primary.hasUpdate}
|
||||
<div class="row-actions">
|
||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
|
||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
||||
</div>
|
||||
{:else if primary.isInstalled}
|
||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
|
||||
{/if}
|
||||
{#if hasVariants}
|
||||
<button class="expand-btn" onclick={() => toggleExpand(base)} title="{variants.length + 1} languages">
|
||||
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
||||
<span class="expand-count">{variants.length + 1}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isExpanded && hasVariants}
|
||||
<div class="variants">
|
||||
{#each variants as v}
|
||||
<div class="variant-row">
|
||||
<span class="lang-tag">{v.lang.toUpperCase()}</span>
|
||||
<span class="variant-name">{v.name}</span>
|
||||
<span class="variant-version">v{v.versionName}</span>
|
||||
{#if v.hasUpdate}<span class="update-badge-small">↑</span>{/if}
|
||||
<div class="variant-actions">
|
||||
{#if working.has(v.pkgName)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if v.hasUpdate}
|
||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
|
||||
{:else if v.isInstalled}
|
||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</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-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:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.4; }
|
||||
.icon-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.panel-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.panel-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); padding: 0 2px; }
|
||||
.ext-row { display: flex; gap: var(--sp-2); }
|
||||
.ext-input { flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 6px var(--sp-3); color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
||||
.ext-input:focus { border-color: var(--border-focus); }
|
||||
.ext-input:disabled { opacity: 0.5; }
|
||||
.ext-input.error { border-color: var(--color-error) !important; }
|
||||
.install-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 14px; 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), opacity var(--t-base); white-space: nowrap; }
|
||||
.install-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.install-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.install-btn.success { background: rgba(107,143,107,0.2); border-color: var(--accent-fg); color: var(--accent-fg); }
|
||||
.repo-loading { display: flex; align-items: center; justify-content: center; padding: var(--sp-3); }
|
||||
.repo-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 2px; }
|
||||
.repo-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.repo-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.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: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; }
|
||||
.tab { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: none; background: none; color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
|
||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.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 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
||||
.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: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); }
|
||||
.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; }
|
||||
.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); }
|
||||
.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; }
|
||||
.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:hover { filter: brightness(1.1); }
|
||||
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.action-btn-dim:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||
.expand-btn { display: flex; align-items: center; gap: 3px; padding: 4px 6px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.expand-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.expand-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); }
|
||||
.variants { display: flex; flex-direction: column; gap: 1px; margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3)); padding-left: var(--sp-3); border-left: 1px solid var(--border-dim); animation: fadeIn 0.1s ease both; }
|
||||
.variant-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||
.variant-row:hover { background: var(--bg-raised); }
|
||||
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); 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); }
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
||||
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const INITIAL_PAGES = 3;
|
||||
const MAX_SOURCES = 12;
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
function parseTags(f: string): string[] { return f.split("+").map((t) => t.trim()).filter(Boolean); }
|
||||
function tagsLabel(tags: string[]): string {
|
||||
if (tags.length === 1) return tags[0];
|
||||
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
||||
}
|
||||
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
||||
const g = (m.genre ?? []).map((x) => x.toLowerCase());
|
||||
return tags.every((t) => g.includes(t.toLowerCase()));
|
||||
}
|
||||
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
|
||||
let i = 0;
|
||||
async function worker() { 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));
|
||||
}
|
||||
const prevNavPage = store.navPage;
|
||||
const tags = $derived(parseTags(store.genreFilter));
|
||||
const primaryTag = $derived(tags[0] ?? "");
|
||||
const label = $derived(tagsLabel(tags));
|
||||
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let sourceManga: Manga[] = $state([]);
|
||||
let loadingInitial = true;
|
||||
let loadingMore = false;
|
||||
let visibleCount = PAGE_SIZE;
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
|
||||
const nextPageMap = new Map<string, number>();
|
||||
let sources: Source[] = $state([]);
|
||||
let abortCtrl: AbortController | null = null;
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]);
|
||||
});
|
||||
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
||||
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
||||
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
|
||||
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
|
||||
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
|
||||
|
||||
async function load(filter: string) {
|
||||
abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
loadingInitial = true;
|
||||
sourceManga = [];
|
||||
libraryManga = [];
|
||||
visibleCount = PAGE_SIZE;
|
||||
nextPageMap.clear();
|
||||
|
||||
const preferredLang = store.settings.preferredExtensionLang || "en";
|
||||
const t = parseTags(filter);
|
||||
const pt = t[0] ?? "";
|
||||
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)])
|
||||
.then(([all, lib]) => { const m = new Map(lib.mangas.nodes.map((x) => [x.id, x])); return all.mangas.nodes.map((x) => m.get(x.id) ?? x); })
|
||||
).then((manga) => { if (!ctrl.signal.aborted) libraryManga = manga; }).catch(() => {});
|
||||
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)),
|
||||
Infinity,
|
||||
).then(async (allSources) => {
|
||||
const srcs = allSources.slice(0, MAX_SOURCES);
|
||||
sources = srcs;
|
||||
for (const src of srcs) nextPageMap.set(src.id, -1);
|
||||
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const ps = getPageSet(src.id, "SEARCH", t);
|
||||
const pageItems: Manga[] = [];
|
||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t);
|
||||
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, query: pt }, ctrl.signal)
|
||||
.then((d) => d.fetchSourceManga),
|
||||
).catch(() => null);
|
||||
if (!result || ctrl.signal.aborted) break;
|
||||
ps.add(page);
|
||||
const matching = t.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, t)) : result.mangas;
|
||||
pageItems.push(...matching);
|
||||
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
|
||||
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
|
||||
}
|
||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||
sourceManga = dedupeMangaById([...sourceManga, ...pageItems]);
|
||||
loadingInitial = false;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) loadingInitial = false;
|
||||
}).catch(() => { if (!ctrl.signal.aborted) loadingInitial = false; });
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
if (hasMoreVisible) { visibleCount += PAGE_SIZE; return; }
|
||||
const srcs = sources.filter((s) => (nextPageMap.get(s.id) ?? -1) > 0);
|
||||
if (!srcs.length) return;
|
||||
loadingMore = true;
|
||||
abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
try {
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
const page = nextPageMap.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||
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, query: primaryTag }, ctrl.signal)
|
||||
.then((d) => d.fetchSourceManga),
|
||||
).catch(() => { nextPageMap.set(src.id, -1); return null; });
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
ps.add(page);
|
||||
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||
const matching = tags.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, tags)) : result.mangas;
|
||||
if (matching.length > 0) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) { visibleCount += PAGE_SIZE; loadingMore = 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(() => { 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 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...store.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); } } },
|
||||
];
|
||||
}
|
||||
|
||||
$effect(() => () => { abortCtrl?.abort(); });
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
|
||||
<ArrowLeft size={13} weight="light" /><span>Back</span>
|
||||
</button>
|
||||
<span class="title">{label}</span>
|
||||
{#if !loadingInitial || filtered.length > 0}
|
||||
<span class="result-count">{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}</span>
|
||||
{/if}
|
||||
{#if !loadingInitial && hasMoreNetwork}
|
||||
<span class="loading-hint">More loading…</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadingInitial && filtered.length === 0}
|
||||
<div class="grid">
|
||||
{#each Array(50) as _}
|
||||
<div class="card-skeleton">
|
||||
<div class="cover-skeleton skeleton"></div>
|
||||
<div class="title-skeleton skeleton"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">No manga found for "{label}".</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#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 }; }}>
|
||||
<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="card-title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#if hasMore}
|
||||
<div class="show-more-cell">
|
||||
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
|
||||
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#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; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.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; background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); flex-shrink: 0; }
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
|
||||
.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; }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.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 { 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); }
|
||||
.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; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||
.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); }
|
||||
.show-more-cell { grid-column: 1/-1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
|
||||
.show-more-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: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.show-more-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -1,152 +0,0 @@
|
||||
.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;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
|
||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||
.searchIcon { 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); }
|
||||
.searchClear {
|
||||
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);
|
||||
}
|
||||
.searchClear:hover { color: var(--text-muted); }
|
||||
|
||||
.clearBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
|
||||
.statsBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.statItem {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.statVal {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--accent-fg);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.statDivider {
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
||||
|
||||
.group { margin-bottom: var(--sp-5); }
|
||||
.groupLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md);
|
||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.row:hover .playIcon { opacity: 1; }
|
||||
|
||||
/* Thumb with session count badge */
|
||||
.thumbWrap { 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);
|
||||
}
|
||||
.sessionBadge {
|
||||
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;
|
||||
letter-spacing: 0.02em;
|
||||
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; }
|
||||
.mangaTitle {
|
||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.chapterName {
|
||||
font-size: var(--text-sm); color: var(--text-muted);
|
||||
display: flex; align-items: center; gap: var(--sp-1); min-width: 0;
|
||||
}
|
||||
.chapterRange {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
color: var(--text-muted); font-size: var(--text-sm);
|
||||
}
|
||||
.rangeSep {
|
||||
color: var(--text-faint); font-size: 10px; flex-shrink: 0;
|
||||
}
|
||||
.pageBadge {
|
||||
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;
|
||||
}
|
||||
.playIcon {
|
||||
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);
|
||||
}
|
||||
.emptyIcon { color: var(--text-faint); }
|
||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||
@@ -0,0 +1,250 @@
|
||||
<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,244 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "@phosphor-icons/react";
|
||||
import { thumbUrl } from "../../lib/client";
|
||||
import { useStore, type HistoryEntry } from "../../store";
|
||||
import s from "./History.module.css";
|
||||
|
||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const 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);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return "Today";
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
// Estimate reading time: ~8 seconds per page, counted from chapter entries
|
||||
// Each unique chapter read ≈ pageCount pages (fallback 30 if unknown)
|
||||
function formatReadTime(minutes: number): string {
|
||||
if (minutes < 1) return "< 1 min";
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
// ── Session grouping ──────────────────────────────────────────────────────────
|
||||
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
export interface ReadingSession {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
latestChapterId: number;
|
||||
latestChapterName: string;
|
||||
latestPageNumber: number;
|
||||
firstChapterName: string;
|
||||
chapterCount: number;
|
||||
readAt: number;
|
||||
}
|
||||
|
||||
function buildSessions(entries: HistoryEntry[]): ReadingSession[] {
|
||||
if (!entries.length) return [];
|
||||
const sessions: ReadingSession[] = [];
|
||||
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];
|
||||
const 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;
|
||||
}
|
||||
|
||||
function groupSessionsByDay(sessions: ReadingSession[]): { label: string; items: ReadingSession[] }[] {
|
||||
const groups = new Map<string, ReadingSession[]>();
|
||||
for (const sess of sessions) {
|
||||
const label = dayLabel(sess.readAt);
|
||||
if (!groups.has(label)) groups.set(label, []);
|
||||
groups.get(label)!.push(sess);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function History() {
|
||||
const history = useStore((s) => s.history);
|
||||
const clearHistory = useStore((s) => s.clearHistory);
|
||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||
const openReader = useStore((s) => s.openReader);
|
||||
const activeChapterList = useStore((s) => s.activeChapterList);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return history;
|
||||
return history.filter(
|
||||
(e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q)
|
||||
);
|
||||
}, [history, search]);
|
||||
|
||||
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
||||
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
if (!history.length) return null;
|
||||
// Unique chapters read
|
||||
const uniqueChapters = new Set(history.map((e) => e.chapterId)).size;
|
||||
// Unique manga read
|
||||
const uniqueManga = new Set(history.map((e) => e.mangaId)).size;
|
||||
// Estimated read time: average ~45 pages/chapter at ~6s/page = ~4.5 min/chapter
|
||||
const estimatedMinutes = Math.round(uniqueChapters * 4.5);
|
||||
return { uniqueChapters, uniqueManga, estimatedMinutes };
|
||||
}, [history]);
|
||||
|
||||
function resumeReading(session: ReadingSession) {
|
||||
// If the chapter list is available in store (user already visited this manga),
|
||||
// open the reader directly for a snappier experience
|
||||
const chapterInList = activeChapterList.find((c) => c.id === session.latestChapterId);
|
||||
if (chapterInList && activeChapterList.length > 0) {
|
||||
openReader(chapterInList, activeChapterList);
|
||||
} else {
|
||||
// Fall back to opening SeriesDetail — it will show the continue button
|
||||
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>History</h1>
|
||||
<div className={s.headerRight}>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input className={s.search} placeholder="Search history…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
||||
)}
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
||||
<Trash size={14} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className={s.statsBar}>
|
||||
<span className={s.statItem}>
|
||||
<span className={s.statVal}>{stats.uniqueChapters}</span>
|
||||
<span className={s.statLabel}>chapters read</span>
|
||||
</span>
|
||||
<span className={s.statDivider} />
|
||||
<span className={s.statItem}>
|
||||
<span className={s.statVal}>{stats.uniqueManga}</span>
|
||||
<span className={s.statLabel}>series</span>
|
||||
</span>
|
||||
<span className={s.statDivider} />
|
||||
<span className={s.statItem}>
|
||||
<span className={s.statVal}>{formatReadTime(stats.estimatedMinutes)}</span>
|
||||
<span className={s.statLabel}>est. read time</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No reading history yet</p>
|
||||
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<Books size={28} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No results for "{search}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{groups.map(({ label, items }) => (
|
||||
<div key={label} className={s.group}>
|
||||
<p className={s.groupLabel}>{label}</p>
|
||||
{items.map((session) => (
|
||||
<button
|
||||
key={`${session.latestChapterId}-${session.readAt}`}
|
||||
className={s.row}
|
||||
onClick={() => resumeReading(session)}
|
||||
>
|
||||
<div className={s.thumbWrap}>
|
||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} className={s.thumb} />
|
||||
{session.chapterCount > 1 && (
|
||||
<span className={s.sessionBadge}>{session.chapterCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={s.info}>
|
||||
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
||||
<span className={s.chapterName}>
|
||||
{session.chapterCount > 1 ? (
|
||||
<span className={s.chapterRange}>
|
||||
{session.firstChapterName}
|
||||
<span className={s.rangeSep}>→</span>
|
||||
{session.latestChapterName}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{session.latestChapterName}
|
||||
{session.latestPageNumber > 1 && (
|
||||
<span className={s.pageBadge}>p.{session.latestPageNumber}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={s.time}>{timeAgo(session.readAt)}</span>
|
||||
<Play size={12} weight="fill" className={s.playIcon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
<script lang="ts">
|
||||
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 { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { store, openReader, COMPLETED_FOLDER_ID, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
||||
import type { HistoryEntry } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
|
||||
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 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`;
|
||||
}
|
||||
|
||||
function focusEl(node: HTMLElement) { node.focus(); }
|
||||
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let extraManga: Manga[] = $state([]);
|
||||
let loadingLibrary: boolean = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
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[]) {
|
||||
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? [];
|
||||
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
||||
if (!missingIds.length) return;
|
||||
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
||||
const valid = results.flatMap(r => r.status === "fulfilled" && r.value ? [r.value] : []);
|
||||
if (valid.length) extraManga = valid;
|
||||
}
|
||||
|
||||
const continueReading = $derived((() => {
|
||||
const seen = new Set<number>();
|
||||
const out: HistoryEntry[] = [];
|
||||
for (const e of store.history) {
|
||||
if (seen.has(e.mangaId)) continue;
|
||||
seen.add(e.mangaId);
|
||||
out.push(e);
|
||||
if (out.length >= 10) break;
|
||||
}
|
||||
return out;
|
||||
})());
|
||||
|
||||
const TOTAL_SLOTS = 4;
|
||||
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
|
||||
|
||||
const resolvedSlots = $derived((() => {
|
||||
const pins = store.settings.heroSlots ?? [null, null, null, null];
|
||||
const slots: HeroSlot[] = [];
|
||||
const first = continueReading[0];
|
||||
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
|
||||
let hi = 1;
|
||||
for (let i = 1; i < TOTAL_SLOTS; i++) {
|
||||
const pinId = pins[i];
|
||||
if (pinId != null) {
|
||||
const manga = libraryManga.find(m => m.id === pinId);
|
||||
if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; }
|
||||
}
|
||||
const entry = continueReading[hi++];
|
||||
slots.push(entry ? { kind: "continue", entry, slotIndex: i } : { kind: "empty", slotIndex: i });
|
||||
}
|
||||
return slots;
|
||||
})());
|
||||
|
||||
let activeIdx = $state(0);
|
||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
||||
const heroThumb = $derived(activeSlot?.kind === "pinned" ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") : activeSlot?.kind === "continue" ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") : "");
|
||||
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 heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
||||
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
||||
|
||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; }
|
||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; }
|
||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; } }
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
||||
if (e.key === "ArrowRight") cycleNext();
|
||||
if (e.key === "ArrowLeft") cyclePrev();
|
||||
}
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
let heroStageH = $state(300);
|
||||
let heroChapters: Chapter[] = $state([]);
|
||||
let loadingHeroChapters = $state(false);
|
||||
let heroChaptersFor: number | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const id = heroMangaId;
|
||||
if (id && id !== heroChaptersFor) untrack(() => loadHeroChapters(id));
|
||||
});
|
||||
|
||||
async function loadHeroChapters(mangaId: number) {
|
||||
heroChaptersFor = mangaId;
|
||||
loadingHeroChapters = true;
|
||||
heroChapters = [];
|
||||
try {
|
||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
||||
if (heroChaptersFor !== mangaId) return;
|
||||
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);
|
||||
const startIdx = Math.max(0, lastReadIdx);
|
||||
heroChapters = all.slice(startIdx, startIdx + 5);
|
||||
} catch { heroChapters = []; }
|
||||
finally { loadingHeroChapters = false; }
|
||||
}
|
||||
|
||||
let resuming = $state(false);
|
||||
|
||||
async function openChapter(chapter: Chapter) {
|
||||
if (!heroMangaId) return;
|
||||
resuming = true;
|
||||
try {
|
||||
let all = heroChapters;
|
||||
if (!all.length) {
|
||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
}
|
||||
openReader(chapter, all);
|
||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
||||
finally { resuming = false; }
|
||||
}
|
||||
|
||||
async function resumeActive() {
|
||||
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
||||
if (!heroEntry) return;
|
||||
const target = heroChapters.find(c => c.id === heroEntry!.chapterId) ?? heroChapters[0];
|
||||
if (target && heroChapters.length) { await openChapter(target); return; }
|
||||
resuming = true;
|
||||
try {
|
||||
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 ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
||||
if (ch) openReader(ch, chapters);
|
||||
else 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; }
|
||||
}
|
||||
|
||||
async function resumeEntry(entry: HistoryEntry) {
|
||||
try {
|
||||
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 ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
||||
if (ch) openReader(ch, chapters);
|
||||
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; }
|
||||
}
|
||||
|
||||
let pickerOpen = $state(false);
|
||||
let pickerSlotIndex: 1|2|3|null = $state(null);
|
||||
let pickerSearch = $state("");
|
||||
|
||||
const pickerResults = $derived(pickerSearch.trim()
|
||||
? libraryManga.filter(m => m.title.toLowerCase().includes(pickerSearch.toLowerCase())).slice(0, 20)
|
||||
: libraryManga.slice(0, 20));
|
||||
|
||||
function openPicker(i: 1|2|3) { pickerSlotIndex = i; pickerOpen = true; pickerSearch = ""; }
|
||||
function closePicker() { pickerOpen = false; pickerSlotIndex = null; }
|
||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
||||
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
||||
|
||||
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
|
||||
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);
|
||||
|
||||
function handleRowWheel(e: WheelEvent) {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
(e.currentTarget as HTMLElement).scrollLeft += e.deltaY;
|
||||
e.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="body">
|
||||
|
||||
<div class="hero-section">
|
||||
<div class="hero-stage" bind:clientHeight={heroStageH} style="--hero-h:{heroStageH}px">
|
||||
|
||||
{#if heroThumb}
|
||||
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
|
||||
{:else}
|
||||
<div class="hero-backdrop hero-bd-empty"></div>
|
||||
{/if}
|
||||
<div class="hero-scrim"></div>
|
||||
|
||||
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
|
||||
{#if heroThumb}
|
||||
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
|
||||
{#if activeSlot?.kind === "continue"}
|
||||
<div class="cover-resume-hint"><Play size={18} weight="fill" /></div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="hero-details">
|
||||
{#if activeSlot?.kind === "empty"}
|
||||
<p class="hero-empty-title">Nothing here yet</p>
|
||||
<p class="hero-empty-sub">{activeSlot.slotIndex === 0 ? "Read a manga to see it here" : "Pin a manga or keep reading to fill this slot"}</p>
|
||||
{#if activeSlot.slotIndex !== 0}
|
||||
<button class="hero-cta" onclick={() => openPicker(activeSlot.slotIndex as 1|2|3)}>
|
||||
<PushPin size={11} weight="fill" /> Pin manga
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="hero-tags">
|
||||
{#if activeSlot?.kind === "continue"}
|
||||
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
|
||||
{:else}
|
||||
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
|
||||
{/if}
|
||||
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
|
||||
<button class="hero-tag hero-tag-genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); }}>{g}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2 class="hero-title">{heroTitle}</h2>
|
||||
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
|
||||
|
||||
{#if heroEntry}
|
||||
<p class="hero-progress">
|
||||
<Clock size={10} weight="light" />
|
||||
{heroEntry.chapterName}
|
||||
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
|
||||
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if heroManga?.description}<p class="hero-desc">{heroManga.description}</p>{/if}
|
||||
|
||||
<div class="hero-actions">
|
||||
{#if activeSlot?.kind === "continue"}
|
||||
<button class="hero-cta" onclick={resumeActive} disabled={resuming}>
|
||||
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
|
||||
</button>
|
||||
{:else if heroManga}
|
||||
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
|
||||
<BookOpen size={11} weight="light" /> View manga
|
||||
</button>
|
||||
{/if}
|
||||
{#if activeSlot?.slotIndex !== 0}
|
||||
{#if activeSlot?.kind === "pinned"}
|
||||
<button class="hero-cta-ghost" onclick={() => unpinSlot(activeSlot.slotIndex as 1|2|3)}>
|
||||
<XIcon size={10} weight="bold" /> Unpin
|
||||
</button>
|
||||
{:else}
|
||||
<button class="hero-cta-ghost" onclick={() => openPicker(activeSlot!.slotIndex as 1|2|3)}>
|
||||
<PushPin size={10} weight="light" /> Pin
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="hero-nav-row">
|
||||
<button class="hero-nav-btn" onclick={cyclePrev} aria-label="Previous"><ArrowLeft size={12} weight="bold" /></button>
|
||||
<div class="hero-dots">
|
||||
{#each resolvedSlots as slot, i}
|
||||
<button class="hero-dot" class:active={activeIdx === i} class:pinned={slot.kind === "pinned"} onclick={() => goToSlot(i)} aria-label="Slot {i + 1}"></button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="hero-nav-btn" onclick={cycleNext} aria-label="Next"><ArrowRight size={12} weight="bold" /></button>
|
||||
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-chapters">
|
||||
<div class="hero-chapters-header"><ListBullets size={11} weight="bold" /><span>Up Next</span></div>
|
||||
|
||||
{#if activeSlot?.kind === "empty"}
|
||||
<p class="hero-chapters-empty">No chapters to show</p>
|
||||
{:else if loadingHeroChapters}
|
||||
{#each Array(4) as _}
|
||||
<div class="chapter-row-sk">
|
||||
<div class="sk sk-num"></div>
|
||||
<div class="sk-info"><div class="sk sk-name"></div><div class="sk sk-meta"></div></div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if heroChapters.length === 0}
|
||||
<p class="hero-chapters-empty">No chapters available</p>
|
||||
{:else}
|
||||
{#each heroChapters as ch (ch.id)}
|
||||
{@const isCurrent = heroEntry?.chapterId === ch.id}
|
||||
<button class="chapter-row" class:chapter-row-current={isCurrent} class:chapter-row-read={ch.isRead && !isCurrent} onclick={() => openChapter(ch)}>
|
||||
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
|
||||
<div class="ch-info">
|
||||
<span class="ch-name">{ch.name}</span>
|
||||
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
|
||||
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
|
||||
{:else if ch.isRead}
|
||||
<span class="ch-meta ch-read">Read</span>
|
||||
{:else if ch.uploadDate}
|
||||
<span class="ch-meta">{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate)*1000).toLocaleDateString("en-US",{month:"short",day:"numeric"})}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if heroManga}
|
||||
<button class="ch-view-all" onclick={() => { if (heroManga) store.activeManga = heroManga; }}>
|
||||
All chapters <ArrowRight size={9} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
|
||||
{#if recentHistory.length > 0}
|
||||
<button class="see-all" onclick={() => setNavPage("history")}>Full History <ArrowRight size={9} weight="bold" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="activity-list">
|
||||
{#if recentHistory.length > 0}
|
||||
{#each recentHistory as entry (entry.chapterId)}
|
||||
<button class="activity-row" onclick={() => resumeEntry(entry)}>
|
||||
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle} class="activity-thumb" loading="lazy" decoding="async" />
|
||||
<div class="activity-info">
|
||||
<span class="activity-title">{entry.mangaTitle}</span>
|
||||
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
|
||||
</div>
|
||||
<span class="activity-time">{timeAgo(entry.readAt)}</span>
|
||||
<span class="activity-play"><Play size={10} weight="fill" /></span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="activity-placeholder">
|
||||
{#each Array(5) as _, i}
|
||||
<div class="activity-row activity-row-sk">
|
||||
<div class="sk-thumb"></div>
|
||||
<div class="activity-info">
|
||||
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
|
||||
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
|
||||
</div>
|
||||
<div class="sk sk-time"></div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="activity-placeholder-overlay">
|
||||
<button class="activity-placeholder-cta" onclick={() => setNavPage("library")}>
|
||||
<BookOpen size={12} weight="light" /> Start reading
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-row">
|
||||
<div class="bottom-col">
|
||||
<div class="bottom-section-hd">
|
||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
||||
{#if completedManga.length > 0}
|
||||
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if completedManga.length > 0}
|
||||
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
||||
{#each completedManga as m (m.id)}
|
||||
<button class="mini-card" onclick={() => store.previewManga = m}>
|
||||
<div class="mini-cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
|
||||
<div class="mini-gradient"></div>
|
||||
<div class="mini-footer">
|
||||
<p class="mini-card-title">{m.title}</p>
|
||||
{#if m.source?.displayName}<p class="mini-card-source">{m.source.displayName}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="bottom-empty">Finish a manga to see it here</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="bottom-divider"></div>
|
||||
|
||||
<div class="bottom-col">
|
||||
<div class="bottom-section-hd">
|
||||
<span class="section-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-icon-wrap stat-fire"><Fire size={16} weight="fill" /></div><div class="stat-body"><span class="stat-val">{stats.currentStreakDays}</span><span class="stat-label">Day streak</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"><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-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>
|
||||
</div>
|
||||
|
||||
{#if pickerOpen}
|
||||
<div class="picker-backdrop" onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}>
|
||||
<div class="picker-modal">
|
||||
<div class="picker-header">
|
||||
<span class="picker-title">Pin manga — slot {(pickerSlotIndex ?? 0) + 1}</span>
|
||||
<button class="picker-close" onclick={closePicker}><XIcon size={13} weight="light" /></button>
|
||||
</div>
|
||||
<div class="picker-search-wrap">
|
||||
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<input class="picker-search" placeholder="Search library…" bind:value={pickerSearch} use:focusEl />
|
||||
</div>
|
||||
<div class="picker-list">
|
||||
{#if loadingLibrary}
|
||||
<p class="picker-empty">Loading…</p>
|
||||
{:else if pickerResults.length === 0}
|
||||
<p class="picker-empty">No results</p>
|
||||
{:else}
|
||||
{#each pickerResults as m (m.id)}
|
||||
<button class="picker-row" onclick={() => pinManga(m)}>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="picker-thumb" loading="lazy" />
|
||||
<div class="picker-info">
|
||||
<span class="picker-manga-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.body { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; min-height: 0; }
|
||||
.hero-section { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; }
|
||||
.hero-stage { position: relative; display: flex; align-items: stretch; height: 374px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
|
||||
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(20px) saturate(2.2) brightness(0.45); transform: scale(1.07); pointer-events: none; z-index: 0; }
|
||||
.hero-bd-empty { background: var(--bg-void); filter: none; }
|
||||
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.55) 100%); }
|
||||
.hero-cover-col { position: relative; z-index: 2; flex-shrink: 0; width: 263px; height: 374px; overflow: hidden; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.08); background: var(--bg-raised); }
|
||||
.hero-cover-col:hover .hero-cover { filter: brightness(1.08); }
|
||||
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
|
||||
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.2s ease; }
|
||||
.hero-cover-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); color: var(--text-faint); }
|
||||
.cover-resume-hint { position: absolute; inset: var(--sp-3); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 36px; background: rgba(0,0,0,0.4); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
|
||||
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-4) var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
|
||||
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
|
||||
.hero-tag { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); border: 1px solid rgba(255,255,255,0.14); }
|
||||
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
|
||||
.hero-tag-genre { cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
|
||||
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
|
||||
.hero-title { font-size: var(--text-xl); font-weight: var(--weight-medium); color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 2px 10px rgba(0,0,0,0.5); }
|
||||
.hero-author { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.48); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.hero-progress { display: flex; align-items: center; gap: 5px; flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.58); letter-spacing: var(--tracking-wide); }
|
||||
.hero-prog-page { color: rgba(255,255,255,0.38); }
|
||||
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.32); }
|
||||
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0; }
|
||||
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.5); flex-shrink: 0; }
|
||||
.hero-empty-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
||||
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
|
||||
.hero-cta { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); white-space: nowrap; }
|
||||
.hero-cta:hover:not(:disabled) { filter: brightness(1.15); }
|
||||
.hero-cta:disabled { opacity: 0.55; cursor: default; }
|
||||
.hero-cta-ghost { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 14px; border-radius: var(--radius-full); background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.13); color: rgba(255,255,255,0.52); cursor: pointer; transition: background var(--t-base), color var(--t-base); white-space: nowrap; }
|
||||
.hero-cta-ghost:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.82); }
|
||||
.hero-nav-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; margin-top: auto; padding-top: var(--sp-2); border-top: 1px solid rgba(255,255,255,0.08); }
|
||||
.hero-nav-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.12); color: rgba(255,255,255,0.6); cursor: pointer; flex-shrink: 0; transition: background var(--t-base), color var(--t-base); }
|
||||
.hero-nav-btn:hover { background: rgba(255,255,255,0.2); color: #fff; }
|
||||
.hero-dots { display: flex; gap: 5px; align-items: center; }
|
||||
.hero-dot { width: 5px; height: 5px; border-radius: 50%; background: rgba(255,255,255,0.22); border: none; cursor: pointer; padding: 0; transition: background var(--t-base), transform var(--t-base); }
|
||||
.hero-dot:hover { background: rgba(255,255,255,0.5); }
|
||||
.hero-dot.active { background: #fff; transform: scale(1.35); }
|
||||
.hero-dot.pinned { background: rgba(168,132,232,0.55); }
|
||||
.hero-dot.pinned.active { background: #c4a8f0; }
|
||||
.hero-counter { font-family: var(--font-ui); font-size: 10px; color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); margin-left: auto; }
|
||||
.hero-chapters { position: relative; z-index: 2; width: clamp(180px, 32%, 240px); flex-shrink: 0; display: flex; flex-direction: column; padding: var(--sp-4) var(--sp-3); gap: 1px; overflow: hidden; }
|
||||
.hero-chapters-header { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.4); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding-bottom: var(--sp-2); margin-bottom: var(--sp-1); border-bottom: 1px solid rgba(255,255,255,0.08); flex-shrink: 0; }
|
||||
.hero-chapters-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.25); letter-spacing: var(--tracking-wide); padding: var(--sp-3) 0; }
|
||||
.chapter-row { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||
.chapter-row:hover { background: rgba(255,255,255,0.07); }
|
||||
.chapter-row-current { background: rgba(255,255,255,0.1) !important; }
|
||||
.ch-num { font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.35); letter-spacing: var(--tracking-wide); flex-shrink: 0; min-width: 36px; }
|
||||
.chapter-row-current .ch-num { color: var(--accent-fg); }
|
||||
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.ch-name { font-size: var(--text-xs); color: rgba(255,255,255,0.75); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chapter-row-read .ch-name { color: rgba(255,255,255,0.35); }
|
||||
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
|
||||
.ch-meta { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); }
|
||||
.ch-read { color: rgba(255,255,255,0.2); }
|
||||
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
|
||||
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
|
||||
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||
.sk { background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
|
||||
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
|
||||
.sk-name { height: 11px; width: 85%; }
|
||||
.sk-meta { height: 9px; width: 50%; }
|
||||
.ch-view-all { display: flex; align-items: center; gap: 4px; margin-top: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); background: none; border: none; cursor: pointer; padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base); }
|
||||
.ch-view-all:hover { color: var(--accent-fg); }
|
||||
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-2); }
|
||||
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-sm); 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-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
||||
.see-all:hover { color: var(--accent-fg); }
|
||||
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
|
||||
.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 .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); }
|
||||
.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-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-time { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.activity-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
||||
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
||||
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-4); padding-bottom: var(--sp-5); }
|
||||
.bottom-col:first-child { padding-right: 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-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
||||
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--sp-3); }
|
||||
|
||||
.mini-card { width: 100%; 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 { 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 { 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-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-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; }
|
||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
|
||||
.stat-card { display: flex; align-items: center; gap: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-3); }
|
||||
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
|
||||
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
|
||||
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
|
||||
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-lg, 1.05rem); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
|
||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
.activity-row-sk { cursor: default; pointer-events: none; }
|
||||
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
|
||||
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); }
|
||||
.sk-title { height: 11px; margin-bottom: 5px; }
|
||||
.sk-sub { height: 9px; }
|
||||
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
|
||||
.activity-placeholder { position: relative; }
|
||||
.activity-placeholder-overlay { position: absolute; left: 0; right: 0; top: 0; bottom: -1px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: var(--sp-4); pointer-events: none; background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%); }
|
||||
.activity-placeholder-cta { pointer-events: all; display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: rgba(255,255,255,0.65); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
|
||||
.activity-placeholder-cta:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.9); }
|
||||
.picker-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
||||
.picker-modal { width: min(460px, calc(100vw - 48px)); max-height: 68vh; 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 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
||||
.picker-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; }
|
||||
.picker-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.picker-close { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; }
|
||||
.picker-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.picker-search-wrap { 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; }
|
||||
.picker-search { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); }
|
||||
.picker-search::placeholder { color: var(--text-faint); }
|
||||
.picker-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
||||
.picker-list::-webkit-scrollbar { display: none; }
|
||||
.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: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; }
|
||||
.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-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.7 } }
|
||||
</style>
|
||||
@@ -1,264 +0,0 @@
|
||||
.root {
|
||||
padding: var(--sp-6);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
/* GPU acceleration for smooth scrolling */
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-5);
|
||||
gap: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Filter tabs */
|
||||
.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);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
|
||||
.tabActive {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
|
||||
.tabCount {
|
||||
font-size: var(--text-2xs);
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
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 28px;
|
||||
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); }
|
||||
|
||||
/* Virtual row — flexbox instead of CSS grid so virtualizer controls height */
|
||||
.virtualRow {
|
||||
display: flex;
|
||||
gap: var(--sp-4);
|
||||
padding: 0 var(--sp-6);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Individual card fills its flex slot */
|
||||
.card {
|
||||
flex: 1 1 130px;
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ghostCard {
|
||||
flex: 1 1 130px;
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .title { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
/* GPU-accelerated compositing */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: filter var(--t-base);
|
||||
/* Hint to compositor */
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
.downloadedBadge {
|
||||
position: absolute;
|
||||
bottom: var(--sp-1);
|
||||
right: var(--sp-1);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-muted);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Skeleton grid still uses CSS grid since it's fixed 12 items */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-6) 0;
|
||||
}
|
||||
|
||||
/* Skeleton */
|
||||
.cardSkeleton { padding: 0; }
|
||||
|
||||
.coverSkeletonWrap {
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.titleSkeleton {
|
||||
height: 12px;
|
||||
margin-top: var(--sp-2);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
/* Ghost cards fill trailing grid space without taking interaction */
|
||||
.ghostCard {
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60%;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
gap: var(--sp-2);
|
||||
text-align: center;
|
||||
line-height: var(--leading-base);
|
||||
}
|
||||
|
||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
||||
.errorDetail { color: var(--text-faint); font-size: var(--text-sm); }
|
||||
/* ── Tag filter ── */
|
||||
.tagPanel {
|
||||
display: flex; flex-wrap: wrap; gap: var(--sp-1);
|
||||
padding: 0 var(--sp-6) var(--sp-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tagChip {
|
||||
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;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.tagChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.tagChipActive {
|
||||
background: var(--accent-muted); border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.tagChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.tagClear {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
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(--color-error);
|
||||
background: none; color: var(--color-error); cursor: pointer;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
.tagClear:hover { background: var(--color-error-bg); }
|
||||
@@ -0,0 +1,327 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_LIBRARY, GET_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||
import { store, setActiveManga, setLibraryFilter } from "../../store/state.svelte";
|
||||
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store/state.svelte";
|
||||
import { COMPLETED_FOLDER_ID } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
const CARD_MIN_W = 130;
|
||||
const CARD_GAP = 16;
|
||||
|
||||
let allManga: Manga[] = $state([]);
|
||||
let extraManga: Manga[] = $state([]); // non-library manga needed for folders (e.g. completed)
|
||||
let loading: boolean = $state(true);
|
||||
let error: string|null = $state(null);
|
||||
let retryCount: number = $state(0);
|
||||
let search: string = $state("");
|
||||
let renderVisible: number = $state(0);
|
||||
let scrollEl: HTMLDivElement;
|
||||
let containerWidth: number = $state(800);
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
let emptyCtx: { x: number; y: number } | null = $state(null);
|
||||
|
||||
let prevChapterId: number | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const wasOpen = prevChapterId !== null;
|
||||
prevChapterId = store.activeChapter?.id ?? null;
|
||||
if (wasOpen && !store.activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
|
||||
});
|
||||
|
||||
function fetchLibrary() {
|
||||
return cache.get(
|
||||
CACHE_KEYS.LIBRARY,
|
||||
() => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes),
|
||||
DEFAULT_TTL_MS,
|
||||
CACHE_GROUPS.LIBRARY,
|
||||
);
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
fetchLibrary()
|
||||
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), store.settings.mangaLinks); error = null; })
|
||||
.catch(e => error = e.message)
|
||||
.finally(() => loading = false);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
retryCount;
|
||||
loading = true; error = null;
|
||||
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
||||
untrack(() => loadData());
|
||||
});
|
||||
|
||||
// Lazily fetch manga that are in a folder but not in the library (e.g. completed but removed from library)
|
||||
$effect(() => {
|
||||
const allIds = new Set(allManga.map(m => m.id));
|
||||
const missingIds = store.settings.folders
|
||||
.flatMap(f => f.mangaIds)
|
||||
.filter(id => !allIds.has(id));
|
||||
if (!missingIds.length) return;
|
||||
const toFetch = [...new Set(missingIds)].filter(id => !extraManga.some(m => m.id === id));
|
||||
if (!toFetch.length) return;
|
||||
untrack(() => {
|
||||
Promise.all(
|
||||
toFetch.map(id =>
|
||||
cache.get(CACHE_KEYS.MANGA(id), () =>
|
||||
gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)
|
||||
).catch(() => null)
|
||||
)
|
||||
).then(results => {
|
||||
const valid = results.filter(Boolean) as Manga[];
|
||||
if (valid.length) extraManga = dedupeMangaById([...extraManga, ...valid]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
|
||||
|
||||
$effect(() => {
|
||||
const f = store.settings.folders.find(f => f.id === store.libraryFilter);
|
||||
if (f && !f.showTab) untrack(() => { store.libraryFilter = "library"; });
|
||||
});
|
||||
|
||||
const isBuiltin = (f: string) => f === "library" || f === "downloaded";
|
||||
|
||||
// All manga available for folder filtering — library + any extras fetched above
|
||||
const folderPool = $derived((() => {
|
||||
const seen = new Set(allManga.map(m => m.id));
|
||||
return [...allManga, ...extraManga.filter(m => !seen.has(m.id))];
|
||||
})());
|
||||
|
||||
const filtered = $derived((() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (store.libraryFilter === "library") {
|
||||
return q ? allManga.filter(m => m.title.toLowerCase().includes(q)) : allManga;
|
||||
}
|
||||
if (store.libraryFilter === "downloaded") {
|
||||
const items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
||||
}
|
||||
const folder = store.settings.folders.find(f => f.id === store.libraryFilter);
|
||||
if (folder) {
|
||||
const items = folderPool.filter(m => folder.mangaIds.includes(m.id));
|
||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
||||
}
|
||||
return [];
|
||||
})());
|
||||
|
||||
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
||||
const visibleManga = $derived(filtered.slice(0, renderVisible));
|
||||
const hasMore = $derived(filtered.length > renderVisible);
|
||||
const remainingCount = $derived(filtered.length - renderVisible);
|
||||
|
||||
$effect(() => { filtered; untrack(() => { renderVisible = store.settings.renderLimit ?? 48; }); });
|
||||
|
||||
const counts = $derived({
|
||||
library: allManga.length,
|
||||
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
||||
...store.settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
|
||||
});
|
||||
|
||||
function loadMore() { renderVisible += store.settings.renderLimit ?? 48; }
|
||||
|
||||
async function removeFromLibrary(manga: Manga) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||
allManga = allManga.filter(m => m.id !== manga.id);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
}
|
||||
|
||||
async function deleteAllDownloads(manga: Manga) {
|
||||
try {
|
||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
||||
const ids = data.chapters.nodes.filter(c => c.isDownloaded).map(c => c.id);
|
||||
if (!ids.length) return;
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||
await Promise.allSettled(ids.map(id => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, downloadCount: 0 } : m);
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function openCtx(e: MouseEvent, m: Manga) { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
const mangaFolders = getMangaFolders(m.id);
|
||||
const folderEntries: MenuEntry[] = store.settings.folders.map(f => {
|
||||
const inFolder = mangaFolders.some(mf => mf.id === f.id);
|
||||
return { label: inFolder ? `Remove from ${f.name}` : `Add to ${f.name}`, icon: Folder, onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id) };
|
||||
});
|
||||
return [
|
||||
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
||||
...(folderEntries.length ? [{ separator: true } as MenuEntry, ...folderEntries] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
||||
];
|
||||
}
|
||||
|
||||
function buildEmptyCtx(): MenuEntry[] {
|
||||
return [{ label: "New folder", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) addFolder(name.trim()); } }];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
||||
ro.observe(scrollEl);
|
||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, loadData);
|
||||
return () => { ro.disconnect(); unsub(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="root"
|
||||
role="presentation"
|
||||
bind:this={scrollEl}
|
||||
oncontextmenu={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
e.preventDefault();
|
||||
emptyCtx = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
>
|
||||
{#if store.settings.libraryBranches ?? true}
|
||||
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
|
||||
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
|
||||
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
|
||||
<path d="M270 220 C255 190 230 175 210 150"/>
|
||||
<path d="M270 220 C290 195 310 185 330 165"/>
|
||||
<path d="M310 400 C290 375 265 368 245 350"/>
|
||||
<path d="M310 400 C330 370 355 362 370 340"/>
|
||||
<path d="M210 150 C195 128 185 108 175 80"/>
|
||||
<path d="M210 150 C225 130 240 122 258 105"/>
|
||||
<path d="M245 350 C228 330 215 315 205 290"/>
|
||||
<path d="M175 80 C168 60 162 42 158 20"/>
|
||||
<path d="M175 80 C185 62 195 50 208 35"/>
|
||||
<path d="M205 290 C196 268 190 250 186 225"/>
|
||||
<path d="M258 105 C268 88 278 72 292 52"/>
|
||||
<path class="anim-branch" d="M186 225 C180 205 176 185 174 160"/>
|
||||
<path class="anim-branch" d="M292 52 C300 36 308 20 318 0"/>
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="center">
|
||||
<p class="error-msg">Could not reach Suwayomi</p>
|
||||
<p class="error-detail">Make sure the server is running, then retry.</p>
|
||||
<button class="retry-btn" onclick={() => retryCount++}>Retry</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<span class="heading">Library</span>
|
||||
<div class="tabs">
|
||||
{#each [["library","Saved"], ["downloaded","Downloaded"]] as [f, label]}
|
||||
<button class="tab" class:active={store.libraryFilter === f} onclick={() => store.libraryFilter = f}>
|
||||
{#if f === "library"}<Books size={11} weight="bold" />
|
||||
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
|
||||
{label}
|
||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each store.settings.folders.filter(f => f.showTab) as folder}
|
||||
<button class="tab" class:active={store.libraryFilter === folder.id} onclick={() => store.libraryFilter = folder.id}>
|
||||
<Folder size={11} weight="bold" />
|
||||
{folder.name}
|
||||
<span class="tab-count">{counts[folder.id] ?? 0}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={13} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="grid">
|
||||
{#each Array(12) as _}
|
||||
<div class="card-skeleton">
|
||||
<div class="cover-skeleton skeleton"></div>
|
||||
<div class="title-skeleton skeleton"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="center">
|
||||
{store.libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
|
||||
: store.libraryFilter === "downloaded" ? "No downloaded manga."
|
||||
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid" style="--cols:{cols}">
|
||||
{#each visibleManga as m (m.id)}
|
||||
<button class="card" onclick={() => store.activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" />
|
||||
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
||||
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
||||
</div>
|
||||
<p class="title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasMore}
|
||||
<div class="load-more-row">
|
||||
<button class="load-more-btn" onclick={loadMore}>
|
||||
Show {Math.min(remainingCount, store.settings.renderLimit ?? 48)} more
|
||||
<span class="load-more-count">({remainingCount} remaining)</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ctx}
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
||||
{/if}
|
||||
{#if emptyCtx}
|
||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { position: relative; padding: var(--sp-5) var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
||||
.branches { position: absolute; top: 0; right: 0; width: 400px; height: 600px; pointer-events: none; z-index: 0; }
|
||||
.branches :global(.anim-branch) { stroke-dasharray: 60; stroke-dashoffset: 60; animation: branchGrow 2.4s ease forwards; }
|
||||
@keyframes branchGrow { to { stroke-dashoffset: 0; } }
|
||||
.header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-4); gap: var(--sp-4); flex-wrap: wrap; }
|
||||
.header-left { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
||||
.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); }
|
||||
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 10px; 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 28px; 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); }
|
||||
.grid { position: relative; z-index: 1; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.card:hover .cover { filter: brightness(1.07); }
|
||||
.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%; transition: filter var(--t-base); will-change: filter; }
|
||||
.badge-dl { position: absolute; bottom: var(--sp-1); right: var(--sp-1); min-width: 18px; height: 18px; padding: 0 3px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--accent-dim); color: var(--accent-fg); border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); }
|
||||
.badge-unread { position: absolute; top: var(--sp-1); left: var(--sp-1); min-width: 18px; height: 18px; padding: 0 4px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--bg-void); color: var(--text-primary); border-radius: var(--radius-sm); border: 1px solid var(--border-strong); }
|
||||
.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; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||
.load-more-row { display: flex; justify-content: center; padding: var(--sp-5) 0 var(--sp-2); position: relative; z-index: 1; }
|
||||
.load-more-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: 8px 20px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.load-more-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.load-more-count { color: var(--text-faint); font-size: var(--text-2xs); }
|
||||
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
|
||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
|
||||
.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); }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,431 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import s from "./Library.module.css";
|
||||
|
||||
const CARD_MIN_W = 130;
|
||||
const CARD_GAP = 16;
|
||||
const ROW_HEIGHT = 260;
|
||||
|
||||
function FadeImg({ src, alt, className, objectFit }: { src: string; alt: string; className?: string; objectFit?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
style={{ objectFit: (objectFit ?? "cover") as any, opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const MangaCard = memo(function MangaCard({
|
||||
manga, onClick, onContextMenu, cropCovers,
|
||||
}: {
|
||||
manga: Manga;
|
||||
onClick: () => void;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
cropCovers: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||
<div className={s.coverWrap}>
|
||||
<FadeImg
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.cover}
|
||||
objectFit={cropCovers ? "cover" : "contain"}
|
||||
/>
|
||||
{!!manga.downloadCount && (
|
||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.title}>{manga.title}</p>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
function fetchLibrary() {
|
||||
return cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((lib) => lib.mangas.nodes)
|
||||
);
|
||||
}
|
||||
|
||||
export default function Library() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||
const folders = useStore((state) => state.settings.folders);
|
||||
const addFolder = useStore((state) => state.addFolder);
|
||||
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
||||
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
||||
|
||||
const loadData = useCallback((showLoading = false) => {
|
||||
if (showLoading) setLoading(true);
|
||||
// Clear a previously failed cache entry so we actually retry the network call
|
||||
if (!cache.has(CACHE_KEYS.LIBRARY)) {
|
||||
// cache miss — fresh fetch, nothing to clear
|
||||
}
|
||||
fetchLibrary()
|
||||
.then((nodes) => { setAllManga(nodes); setError(null); })
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Initial load — delayed on first mount so the server has time to start.
|
||||
// retryCount bumps force a re-run; manual retries clear the cache first.
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
||||
loadData(false);
|
||||
|
||||
// Re-fetch when library cache is invalidated by other pages
|
||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false));
|
||||
return unsub;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [retryCount]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
}, [libraryFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
||||
if (activeFolder && !activeFolder.showTab) setLibraryFilter("library");
|
||||
}, [folders]);
|
||||
|
||||
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items = allManga;
|
||||
if (libraryFilter === "library") {
|
||||
items = items.filter((m) => m.inLibrary);
|
||||
} else if (libraryFilter === "downloaded") {
|
||||
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
||||
} else if (!isBuiltinFilter) {
|
||||
const folder = folders.find((f) => f.id === libraryFilter);
|
||||
if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id));
|
||||
}
|
||||
if (libraryTagFilter.length > 0)
|
||||
items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag)));
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
||||
}
|
||||
return items;
|
||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
||||
|
||||
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const result: Manga[][] = [];
|
||||
for (let i = 0; i < filtered.length; i += cols)
|
||||
result.push(filtered.slice(i, i + cols));
|
||||
return result;
|
||||
}, [filtered, cols]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 3,
|
||||
});
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(m: Manga) => () => setActiveManga(m),
|
||||
[setActiveManga]
|
||||
);
|
||||
|
||||
async function removeFromLibrary(manga: Manga) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||
// Optimistic update first, then invalidate cache
|
||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
}
|
||||
|
||||
async function deleteAllDownloads(manga: Manga) {
|
||||
try {
|
||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
||||
const downloadedChapters = data.chapters.nodes.filter((c) => c.isDownloaded);
|
||||
const ids = downloadedChapters.map((c) => c.id);
|
||||
if (!ids.length) return;
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||
await Promise.allSettled(ids.map((id) => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault();
|
||||
const x = Math.min(e.clientX, window.innerWidth - 208);
|
||||
const y = Math.min(e.clientY, window.innerHeight - 168);
|
||||
setCtx({ x, y, manga: m });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
const mangaFolderEntries: ContextMenuEntry[] = folders.map((f) => {
|
||||
const inFolder = f.mangaIds.includes(m.id);
|
||||
return {
|
||||
label: inFolder ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={inFolder ? "fill" : "light"} />,
|
||||
onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id),
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Open",
|
||||
icon: <BookOpen size={13} weight="light" />,
|
||||
onClick: () => setActiveManga(m),
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: m.inLibrary ? "Remove from library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
danger: m.inLibrary,
|
||||
onClick: () => m.inLibrary
|
||||
? removeFromLibrary(m)
|
||||
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => {
|
||||
setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
})
|
||||
.catch(console.error),
|
||||
},
|
||||
{
|
||||
label: "Delete all downloads",
|
||||
icon: <Trash size={13} weight="light" />,
|
||||
danger: true,
|
||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
||||
onClick: () => deleteAllDownloads(m),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...mangaFolderEntries,
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) {
|
||||
const id = addFolder(name.trim());
|
||||
assignMangaToFolder(id, m.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildEmptyCtxItems(): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: "New folder",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) addFolder(name.trim());
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g)));
|
||||
return Array.from(tagSet).sort();
|
||||
}, [allManga]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const result: Record<string, number> = {
|
||||
all: allManga.length,
|
||||
library: allManga.filter((m) => m.inLibrary).length,
|
||||
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
||||
};
|
||||
folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; });
|
||||
return result;
|
||||
}, [allManga, folders]);
|
||||
|
||||
if (error) return (
|
||||
<div className={s.center}>
|
||||
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
||||
<p className={s.errorDetail}>Make sure the server is running, then retry.</p>
|
||||
<button
|
||||
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||
onClick={() => setRetryCount((c) => c + 1)}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={s.root}
|
||||
ref={scrollRef}
|
||||
onContextMenu={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
e.preventDefault();
|
||||
setEmptyCtx({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Library</h1>
|
||||
<div className={s.tabs}>
|
||||
{(["library", "downloaded", "all"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setLibraryFilter(f)}
|
||||
>
|
||||
{f === "library" ? (
|
||||
<><Books size={11} weight="bold" /> Saved</>
|
||||
) : f === "downloaded" ? (
|
||||
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
||||
) : <>All</>}
|
||||
<span className={s.tabCount}>{counts[f]}</span>
|
||||
</button>
|
||||
))}
|
||||
{folders.filter((f) => f.showTab).map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
className={[s.tab, libraryFilter === folder.id ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setLibraryFilter(folder.id)}
|
||||
>
|
||||
<Folder size={11} weight="bold" />
|
||||
{folder.name}
|
||||
<span className={s.tabCount}>{counts[folder.id] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={13} className={s.searchIcon} weight="light" />
|
||||
<input
|
||||
className={s.search}
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className={s.tagPanel}>
|
||||
{libraryTagFilter.length > 0 && (
|
||||
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
||||
<X size={11} weight="bold" /> Clear
|
||||
</button>
|
||||
)}
|
||||
{allTags.map((tag) => {
|
||||
const active = libraryTagFilter.includes(tag);
|
||||
return (
|
||||
<button key={tag}
|
||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
||||
onClick={() => setGenreFilter(tag)}>
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className={s.grid}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={[s.coverSkeletonWrap, "skeleton"].join(" ")} />
|
||||
<div className={[s.titleSkeleton, "skeleton"].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.center}>
|
||||
{libraryFilter === "library"
|
||||
? "No manga saved to library, browse sources to add some."
|
||||
: libraryFilter === "downloaded"
|
||||
? "No downloaded manga."
|
||||
: !isBuiltinFilter
|
||||
? "No manga in this folder yet. Right-click manga to assign them."
|
||||
: "No manga found."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const rowManga = rows[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: virtualRow.start,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
className={s.virtualRow}
|
||||
>
|
||||
{rowManga.map((m) => (
|
||||
<MangaCard
|
||||
key={m.id}
|
||||
manga={m}
|
||||
onClick={handleCardClick(m)}
|
||||
onContextMenu={(e) => openCtx(e, m)}
|
||||
cropCovers={settings.libraryCropCovers}
|
||||
/>
|
||||
))}
|
||||
{virtualRow.index === rows.length - 1 &&
|
||||
Array.from({ length: cols - rowManga.length }).map((_, i) => (
|
||||
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
{emptyCtx && (
|
||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtxItems()} onClose={() => setEmptyCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,628 +0,0 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl);
|
||||
width: 520px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.modalTitleLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.modalTitleManga {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
/* ── Steps ── */
|
||||
.steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
opacity: 0.4;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
|
||||
.stepActive { opacity: 1; }
|
||||
.stepDone { opacity: 0.6; }
|
||||
|
||||
.stepDot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepActive .stepDot {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.stepLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stepActive .stepLabel { color: var(--text-secondary); }
|
||||
|
||||
.steps .step + .step::before {
|
||||
content: "›";
|
||||
color: var(--text-faint);
|
||||
margin-right: var(--sp-1);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.centered {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-8);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* ── Source list ── */
|
||||
.sourceList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.sourceRow {
|
||||
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);
|
||||
}
|
||||
.sourceRow:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.sourceRowActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
|
||||
.sourceIcon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.sourceInfo { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||
|
||||
.sourceName {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sourceMeta {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.sourceArrow {
|
||||
color: var(--text-faint);
|
||||
opacity: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.sourceRow:hover .sourceArrow { opacity: 1; }
|
||||
|
||||
/* ── Search step ── */
|
||||
.searchStep {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 var(--sp-3) 0 var(--sp-2);
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||
|
||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
padding: 7px 0;
|
||||
}
|
||||
.searchInput::placeholder { color: var(--text-faint); }
|
||||
|
||||
.searchBtn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.backBtn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.backBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.backBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.resultRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 7px var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.resultRow:hover:not(:disabled) { background: var(--bg-raised); }
|
||||
.resultRow:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.resultCoverWrap {
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resultCover { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.resultTitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Skeletons */
|
||||
.skResult {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 7px var(--sp-2);
|
||||
}
|
||||
|
||||
.skCover {
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skMeta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.skTitle { height: 13px; width: 65%; border-radius: var(--radius-sm); }
|
||||
|
||||
/* ── Confirm step ── */
|
||||
.confirmStep {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
}
|
||||
|
||||
.confirmRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.confirmManga {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.confirmCoverWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.confirmCover { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.confirmTitle {
|
||||
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);
|
||||
}
|
||||
|
||||
.confirmSource {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirmArrow { color: var(--text-faint); flex-shrink: 0; }
|
||||
|
||||
.confirmStats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
}
|
||||
|
||||
.statRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.statVal {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.confirmNote {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
line-height: var(--leading-base);
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.migrateBtn {
|
||||
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: 7px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent-fg);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.migrateBtn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||
.migrateBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-error);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
||||
}
|
||||
/* ── Source context pill (step 2 header) ── */
|
||||
.searchContext {
|
||||
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;
|
||||
}
|
||||
|
||||
.searchContextIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchContextName {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.searchContextChange {
|
||||
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);
|
||||
}
|
||||
.searchContextChange:hover { opacity: 0.75; }
|
||||
|
||||
/* ── Result row: updated layout with similarity ── */
|
||||
.resultInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resultMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.bestMatchBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
border: 1px solid var(--accent-dim);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.simBar {
|
||||
width: 48px;
|
||||
height: 3px;
|
||||
background: var(--bg-overlay);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.simFill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.simLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Confirm step additions ── */
|
||||
.confirmDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.confirmTag {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.confirmTagNew {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.statGood { color: var(--color-success) !important; }
|
||||
.statWarn { color: #d97706 !important; }
|
||||
.statBad { color: var(--color-error) !important; }
|
||||
|
||||
.chapterDiff {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: #d97706;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
|
||||
.warnBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: rgba(217, 119, 6, 0.08);
|
||||
border: 1px solid rgba(217, 119, 6, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
color: #d97706;
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
<script lang="ts">
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
manga: Manga;
|
||||
currentChapters: Chapter[];
|
||||
onClose: () => void;
|
||||
onMigrated: (newManga: Manga) => void;
|
||||
}
|
||||
let { manga, currentChapters, onClose, onMigrated }: Props = $props();
|
||||
|
||||
type Step = "source" | "search" | "confirm";
|
||||
|
||||
interface Match {
|
||||
manga: Manga;
|
||||
chapters: Chapter[];
|
||||
readCount: number;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wordsA = new Set(norm(a));
|
||||
const wordsB = new Set(norm(b));
|
||||
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
||||
const union = new Set([...wordsA, ...wordsB]).size;
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
let step: Step = $state("source");
|
||||
let sources: Source[] = $state([]);
|
||||
let loadingSources = $state(true);
|
||||
let selectedSource: Source | null = $state(null);
|
||||
const _initialTitle = manga.title;
|
||||
let query = $state(_initialTitle);
|
||||
let results: { manga: Manga; similarity: number }[] = $state([]);
|
||||
let searching = $state(false);
|
||||
let selectedMatch: Match | null = $state(null);
|
||||
let loadingMatchId: number | null = $state(null);
|
||||
let migrating = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
const readCount = $derived(currentChapters.filter((c) => c.isRead).length);
|
||||
const totalCount = $derived(currentChapters.length);
|
||||
const chapterDiff = $derived(selectedMatch ? selectedMatch.chapters.length - totalCount : 0);
|
||||
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
|
||||
const stepIdx = $derived(STEPS.indexOf(step));
|
||||
|
||||
$effect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => { sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id); })
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingSources = false; });
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||
|
||||
async function searchSource(src: Source, q: string) {
|
||||
if (!src || !q.trim()) return;
|
||||
searching = true; results = []; error = null;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
|
||||
});
|
||||
const scored = d.fetchSourceManga.mangas.map((m) => ({
|
||||
manga: m,
|
||||
similarity: titleSimilarity(manga.title, m.title),
|
||||
}));
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
results = scored;
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickSource(src: Source) {
|
||||
selectedSource = src;
|
||||
step = "search";
|
||||
searchSource(src, query);
|
||||
}
|
||||
|
||||
async function selectMatch(m: Manga, similarity: number) {
|
||||
loadingMatchId = m.id; error = null;
|
||||
try {
|
||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
||||
const chapters = d.fetchChapters.chapters;
|
||||
const matchReadCount = chapters.filter((c) => {
|
||||
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
||||
return old?.isRead;
|
||||
}).length;
|
||||
selectedMatch = { manga: m, chapters, readCount: matchReadCount, similarity };
|
||||
step = "confirm";
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loadingMatchId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
if (!selectedMatch) return;
|
||||
migrating = true; error = null;
|
||||
try {
|
||||
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
||||
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
||||
|
||||
const toMarkRead: number[] = [];
|
||||
const toMarkBookmarked: number[] = [];
|
||||
const progressUpdates: { id: number; lastPageRead: number }[] = [];
|
||||
|
||||
for (const nc of newChapters) {
|
||||
const key = Math.round(nc.chapterNumber * 100);
|
||||
const old = oldByNum.get(key);
|
||||
if (!old) continue;
|
||||
if (old.isRead) toMarkRead.push(nc.id);
|
||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
||||
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
||||
}
|
||||
|
||||
if (toMarkRead.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||
if (toMarkBookmarked.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||
for (const { id, lastPageRead } of progressUpdates)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
||||
|
||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
||||
|
||||
onMigrated({ ...newManga, inLibrary: true });
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
migrating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="modal">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span class="modal-title-label">Migrate source</span>
|
||||
<span class="modal-title-manga">{manga.title}</span>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<!-- Step indicators -->
|
||||
<div class="steps">
|
||||
{#each STEPS as st, i}
|
||||
<div class="step" class:step-active={step === st} class:step-done={i < stepIdx}>
|
||||
<span class="step-dot">
|
||||
{#if i < stepIdx}<Check size={9} weight="bold" />{:else}{i + 1}{/if}
|
||||
</span>
|
||||
<span class="step-label">
|
||||
{st === "source" ? "Pick source" : st === "search" ? (selectedSource ? selectedSource.displayName : "Search") : "Confirm"}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="body">
|
||||
|
||||
<!-- Step 1: Pick source -->
|
||||
{#if step === "source"}
|
||||
<div class="source-list">
|
||||
{#if loadingSources}
|
||||
<div class="centered">
|
||||
<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}
|
||||
{#each sources as src}
|
||||
<button
|
||||
class="source-row"
|
||||
class:source-row-active={selectedSource?.id === src.id}
|
||||
onclick={() => pickSource(src)}>
|
||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<div class="source-info">
|
||||
<span class="source-name">{src.displayName}</span>
|
||||
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||
</div>
|
||||
<ArrowRight size={13} weight="light" class="source-arrow" />
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Search & pick match -->
|
||||
{:else if step === "search"}
|
||||
<div class="search-step">
|
||||
|
||||
<!-- Source context pill -->
|
||||
{#if selectedSource}
|
||||
<div class="search-context">
|
||||
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="search-context-name">{selectedSource.displayName}</span>
|
||||
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="search-row">
|
||||
<div class="search-bar">
|
||||
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
||||
<input class="search-input" bind:value={query}
|
||||
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||
placeholder="Search title…" autofocus />
|
||||
</div>
|
||||
<button class="search-btn"
|
||||
onclick={() => selectedSource && searchSource(selectedSource, query)}
|
||||
disabled={searching || !selectedSource}>
|
||||
{#if searching}
|
||||
<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
<MagnifyingGlass size={12} weight="bold" /> Search
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
|
||||
|
||||
<div class="results">
|
||||
{#if searching}
|
||||
{#each Array(6) as _, i}
|
||||
<div class="sk-result">
|
||||
<div class="skeleton sk-cover"></div>
|
||||
<div class="sk-meta">
|
||||
<div class="skeleton sk-title"></div>
|
||||
<div class="skeleton sk-title" style="width:40%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each results as { manga: m, similarity }, idx}
|
||||
<button class="result-row"
|
||||
onclick={() => selectMatch(m, similarity)}
|
||||
disabled={loadingMatchId !== null}>
|
||||
<div class="result-cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" />
|
||||
</div>
|
||||
<div class="result-info">
|
||||
<span class="result-title">{m.title}</span>
|
||||
<div class="result-meta">
|
||||
{#if idx === 0 && similarity > 0.5}
|
||||
<span class="best-match-badge"><Sparkle size={9} weight="fill" /> Best match</span>
|
||||
{/if}
|
||||
<span class="sim-bar"><span class="sim-fill" style="width:{Math.round(similarity * 100)}%"></span></span>
|
||||
<span class="sim-label">{Math.round(similarity * 100)}% match</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if loadingMatchId === m.id}
|
||||
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" />
|
||||
{:else}
|
||||
<ArrowRight size={13} weight="light" style="color:var(--text-faint);flex-shrink:0;opacity:0.5" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if !searching && results.length === 0 && !error}
|
||||
<div class="centered">
|
||||
<span class="hint">{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Confirm -->
|
||||
{:else if step === "confirm" && selectedMatch}
|
||||
<div class="confirm-step">
|
||||
<div class="confirm-row">
|
||||
<div class="confirm-manga">
|
||||
<div class="confirm-cover-wrap">
|
||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="confirm-cover" />
|
||||
</div>
|
||||
<p class="confirm-title">{manga.title}</p>
|
||||
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
|
||||
<span class="confirm-tag">Current</span>
|
||||
</div>
|
||||
|
||||
<div class="confirm-divider">
|
||||
<ArrowRight size={16} weight="light" class="confirm-arrow" />
|
||||
</div>
|
||||
|
||||
<div class="confirm-manga">
|
||||
<div class="confirm-cover-wrap">
|
||||
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} class="confirm-cover" />
|
||||
</div>
|
||||
<p class="confirm-title">{selectedMatch.manga.title}</p>
|
||||
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
|
||||
<span class="confirm-tag confirm-tag-new">New</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="confirm-stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Title match</span>
|
||||
<span class="stat-val"
|
||||
class:stat-good={selectedMatch.similarity > 0.7}
|
||||
class:stat-warn={selectedMatch.similarity > 0.4 && selectedMatch.similarity <= 0.7}
|
||||
class:stat-bad={selectedMatch.similarity <= 0.4}>
|
||||
{Math.round(selectedMatch.similarity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Chapters on new source</span>
|
||||
<span class="stat-val" class:stat-warn={chapterDiff < -5}>
|
||||
{selectedMatch.chapters.length}
|
||||
{#if chapterDiff !== 0}
|
||||
<span class="chapter-diff">{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Read progress to carry over</span>
|
||||
<span class="stat-val">{selectedMatch.readCount} / {readCount} chapters</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if chapterDiff < -5}
|
||||
<div class="warn-box">
|
||||
<Warning size={13} weight="light" />
|
||||
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="confirm-note">The current entry will be removed from your library. Downloads are not transferred.</p>
|
||||
|
||||
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
|
||||
|
||||
<div class="confirm-actions">
|
||||
<button class="back-btn" onclick={() => step = "search"} disabled={migrating}>Back</button>
|
||||
<button class="migrate-btn" onclick={migrate} disabled={migrating}>
|
||||
{#if migrating}
|
||||
<CircleNotch size={13} weight="light" class="anim-spin" /> Migrating…
|
||||
{:else}
|
||||
<Check size={13} weight="bold" /> Migrate
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn 0.1s ease both; }
|
||||
.modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 520px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
|
||||
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.modal-title { display: flex; flex-direction: column; gap: 2px; }
|
||||
.modal-title-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.modal-title-manga { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); 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); }
|
||||
|
||||
/* Steps */
|
||||
.steps { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.step { display: flex; align-items: center; gap: var(--sp-2); opacity: 0.4; transition: opacity var(--t-base); }
|
||||
.step + .step::before { content: "›"; color: var(--text-faint); margin-right: var(--sp-1); font-size: var(--text-sm); }
|
||||
.step-active { opacity: 1; }
|
||||
.step-done { opacity: 0.6; }
|
||||
.step-dot { width: 18px; height: 18px; border-radius: 50%; background: var(--bg-raised); border: 1px solid var(--border-base); display: flex; align-items: center; justify-content: center; font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); flex-shrink: 0; }
|
||||
.step-active .step-dot { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.step-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
|
||||
.step-active .step-label { color: var(--text-secondary); }
|
||||
|
||||
/* Body */
|
||||
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.centered { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
|
||||
.hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
/* Source list */
|
||||
.source-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
|
||||
.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-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); }
|
||||
.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-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); }
|
||||
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
||||
|
||||
/* 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-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; }
|
||||
.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:hover { opacity: 0.75; }
|
||||
.search-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
.search-bar { flex: 1; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 0 var(--sp-3) 0 var(--sp-2); transition: border-color var(--t-base); }
|
||||
.search-bar:focus-within { border-color: var(--border-strong); }
|
||||
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
|
||||
.search-input { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); padding: 7px 0; }
|
||||
.search-input::placeholder { color: var(--text-faint); }
|
||||
.search-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1); transition: filter var(--t-base); }
|
||||
.search-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.search-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.results { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
|
||||
.result-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast); }
|
||||
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
||||
.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 { width: 100%; height: 100%; object-fit: cover; }
|
||||
.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-meta { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.best-match-badge { display: inline-flex; align-items: center; gap: 3px; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||
.sim-bar { width: 48px; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; display: inline-block; }
|
||||
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.2s ease; }
|
||||
.sim-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
|
||||
/* Skeletons */
|
||||
.sk-result { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); }
|
||||
.sk-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||
.sk-meta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.sk-title { height: 13px; width: 65%; border-radius: var(--radius-sm); }
|
||||
|
||||
/* Confirm step */
|
||||
.confirm-step { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) var(--sp-5); }
|
||||
.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-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; }
|
||||
.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-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
:global(.confirm-arrow) { color: var(--text-faint); }
|
||||
.confirm-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: var(--bg-raised); border: 1px solid var(--border-dim); color: var(--text-faint); }
|
||||
.confirm-tag-new { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.confirm-stats { display: flex; flex-direction: column; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); }
|
||||
.stat-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
|
||||
.stat-good { color: var(--color-success) !important; }
|
||||
.stat-warn { color: #d97706 !important; }
|
||||
.stat-bad { color: var(--color-error) !important; }
|
||||
.chapter-diff { font-family: var(--font-ui); font-size: var(--text-2xs); color: #d97706; letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
|
||||
.warn-box { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: rgba(217,119,6,0.08); border: 1px solid rgba(217,119,6,0.25); border-radius: var(--radius-md); font-size: var(--text-xs); color: #d97706; line-height: var(--leading-snug); }
|
||||
.confirm-note { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-base); }
|
||||
.confirm-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); flex-shrink: 0; }
|
||||
.back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.back-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.back-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.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: 7px 16px; border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
|
||||
.migrate-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||
.migrate-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Error */
|
||||
.error { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); color: var(--color-error); padding: var(--sp-2) var(--sp-3); background: rgba(180,60,60,0.08); border-radius: var(--radius-md); border: 1px solid rgba(180,60,60,0.2); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,372 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||
import s from "./MigrateModal.module.css";
|
||||
|
||||
interface Props {
|
||||
manga: Manga;
|
||||
currentChapters: Chapter[];
|
||||
onClose: () => void;
|
||||
onMigrated: (newManga: Manga) => void;
|
||||
}
|
||||
|
||||
type Step = "source" | "search" | "confirm";
|
||||
|
||||
interface Match {
|
||||
manga: Manga;
|
||||
chapters: Chapter[];
|
||||
readCount: number;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
// Simple title similarity: normalise → word overlap / Jaccard
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wordsA = new Set(norm(a));
|
||||
const wordsB = new Set(norm(b));
|
||||
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
||||
const union = new Set([...wordsA, ...wordsB]).size;
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
|
||||
const [step, setStep] = useState<Step>("source");
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loadingSources, setLoadingSources] = useState(true);
|
||||
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
||||
const [query, setQuery] = useState(manga.title);
|
||||
const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||
const [loadingMatchId, setLoadingMatchId] = useState<number | null>(null);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => setSources(d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id)))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingSources(false));
|
||||
}, []);
|
||||
|
||||
const searchSource = useCallback(async (src: Source, q: string) => {
|
||||
if (!src || !q.trim()) return;
|
||||
setSearching(true);
|
||||
setResults([]);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
|
||||
});
|
||||
const scored = d.fetchSourceManga.mangas.map((m) => ({
|
||||
manga: m,
|
||||
similarity: titleSimilarity(manga.title, m.title),
|
||||
}));
|
||||
// Sort by similarity desc so best matches float to top
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
setResults(scored);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, [manga.title]);
|
||||
|
||||
function pickSource(src: Source) {
|
||||
setSelectedSource(src);
|
||||
setStep("search");
|
||||
// Auto-search immediately with original title
|
||||
searchSource(src, query);
|
||||
}
|
||||
|
||||
async function selectMatch(m: Manga, similarity: number) {
|
||||
setLoadingMatchId(m.id);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
||||
const chapters = d.fetchChapters.chapters;
|
||||
const readCount = chapters.filter((c) => {
|
||||
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
||||
return old?.isRead;
|
||||
}).length;
|
||||
setSelectedMatch({ manga: m, chapters, readCount, similarity });
|
||||
setStep("confirm");
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoadingMatchId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
if (!selectedMatch) return;
|
||||
setMigrating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
||||
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
||||
|
||||
const toMarkRead: number[] = [];
|
||||
const toMarkBookmarked: number[] = [];
|
||||
const progressUpdates: { id: number; lastPageRead: number }[] = [];
|
||||
|
||||
for (const nc of newChapters) {
|
||||
const key = Math.round(nc.chapterNumber * 100);
|
||||
const old = oldByNum.get(key);
|
||||
if (!old) continue;
|
||||
if (old.isRead) toMarkRead.push(nc.id);
|
||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
||||
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
||||
}
|
||||
|
||||
if (toMarkRead.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||
if (toMarkBookmarked.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||
for (const { id, lastPageRead } of progressUpdates)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
||||
|
||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
||||
|
||||
onMigrated({ ...newManga, inLibrary: true });
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setMigrating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const readCount = currentChapters.filter((c) => c.isRead).length;
|
||||
const totalCount = currentChapters.length;
|
||||
|
||||
const chapterDiff = selectedMatch
|
||||
? selectedMatch.chapters.length - totalCount
|
||||
: 0;
|
||||
|
||||
const STEPS: Step[] = ["source", "search", "confirm"];
|
||||
const stepIdx = STEPS.indexOf(step);
|
||||
|
||||
return (
|
||||
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className={s.modal}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className={s.modalHeader}>
|
||||
<div className={s.modalTitle}>
|
||||
<span className={s.modalTitleLabel}>Migrate source</span>
|
||||
<span className={s.modalTitleManga}>{manga.title}</span>
|
||||
</div>
|
||||
<button className={s.closeBtn} onClick={onClose}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{/* ── Step indicators ── */}
|
||||
<div className={s.steps}>
|
||||
{STEPS.map((st, i) => (
|
||||
<div key={st}
|
||||
className={[s.step, step === st ? s.stepActive : "", i < stepIdx ? s.stepDone : ""].join(" ").trim()}>
|
||||
<span className={s.stepDot}>
|
||||
{i < stepIdx ? <Check size={9} weight="bold" /> : i + 1}
|
||||
</span>
|
||||
<span className={s.stepLabel}>
|
||||
{st === "source" ? "Pick source"
|
||||
: st === "search" ? (selectedSource ? selectedSource.displayName : "Search")
|
||||
: "Confirm"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={s.body}>
|
||||
|
||||
{/* ── Step 1: Pick source ── */}
|
||||
{step === "source" && (
|
||||
<div className={s.sourceList}>
|
||||
{loadingSources ? (
|
||||
<div className={s.centered}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : sources.length === 0 ? (
|
||||
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
||||
) : (
|
||||
sources.map((src) => (
|
||||
<button key={src.id}
|
||||
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
||||
onClick={() => pickSource(src)}>
|
||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<div className={s.sourceInfo}>
|
||||
<span className={s.sourceName}>{src.displayName}</span>
|
||||
<span className={s.sourceMeta}>{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||
</div>
|
||||
<ArrowRight size={13} weight="light" className={s.sourceArrow} />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Search & pick match ── */}
|
||||
{step === "search" && (
|
||||
<div className={s.searchStep}>
|
||||
|
||||
{/* Source context pill */}
|
||||
{selectedSource && (
|
||||
<div className={s.searchContext}>
|
||||
<img src={thumbUrl(selectedSource.iconUrl)} alt="" className={s.searchContextIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.searchContextName}>{selectedSource.displayName}</span>
|
||||
<button className={s.searchContextChange} onClick={() => { setStep("source"); setResults([]); }}>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.searchRow}>
|
||||
<div className={s.searchBar}>
|
||||
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
||||
<input className={s.searchInput} value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||
placeholder="Search title…"
|
||||
autoFocus />
|
||||
</div>
|
||||
<button className={s.searchBtn}
|
||||
onClick={() => selectedSource && searchSource(selectedSource, query)}
|
||||
disabled={searching || !selectedSource}>
|
||||
{searching
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: <><MagnifyingGlass size={12} weight="bold" /> Search</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
||||
|
||||
<div className={s.results}>
|
||||
{searching && Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className={s.skResult}>
|
||||
<div className={["skeleton", s.skCover].join(" ")} />
|
||||
<div className={s.skMeta}>
|
||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||
<div className={["skeleton", s.skTitle].join(" ")} style={{ width: "40%" }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!searching && results.map(({ manga: m, similarity }, idx) => (
|
||||
<button key={m.id} className={s.resultRow}
|
||||
onClick={() => selectMatch(m, similarity)}
|
||||
disabled={loadingMatchId !== null}>
|
||||
<div className={s.resultCoverWrap}>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
||||
</div>
|
||||
<div className={s.resultInfo}>
|
||||
<span className={s.resultTitle}>{m.title}</span>
|
||||
<div className={s.resultMeta}>
|
||||
{idx === 0 && similarity > 0.5 && (
|
||||
<span className={s.bestMatchBadge}>
|
||||
<Sparkle size={9} weight="fill" /> Best match
|
||||
</span>
|
||||
)}
|
||||
<span className={s.simBar}>
|
||||
<span className={s.simFill} style={{ width: `${Math.round(similarity * 100)}%` }} />
|
||||
</span>
|
||||
<span className={s.simLabel}>{Math.round(similarity * 100)}% match</span>
|
||||
</div>
|
||||
</div>
|
||||
{loadingMatchId === m.id
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
|
||||
: <ArrowRight size={13} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0, opacity: 0.5 }} />}
|
||||
</button>
|
||||
))}
|
||||
{!searching && results.length === 0 && !error && (
|
||||
<div className={s.centered}>
|
||||
<span className={s.hint}>{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Confirm ── */}
|
||||
{step === "confirm" && selectedMatch && (
|
||||
<div className={s.confirmStep}>
|
||||
<div className={s.confirmRow}>
|
||||
<div className={s.confirmManga}>
|
||||
<div className={s.confirmCoverWrap}>
|
||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.confirmCover} />
|
||||
</div>
|
||||
<p className={s.confirmTitle}>{manga.title}</p>
|
||||
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
||||
<span className={s.confirmTag}>Current</span>
|
||||
</div>
|
||||
|
||||
<div className={s.confirmDivider}>
|
||||
<ArrowRight size={16} weight="light" className={s.confirmArrow} />
|
||||
</div>
|
||||
|
||||
<div className={s.confirmManga}>
|
||||
<div className={s.confirmCoverWrap}>
|
||||
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} className={s.confirmCover} />
|
||||
</div>
|
||||
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
||||
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
||||
<span className={[s.confirmTag, s.confirmTagNew].join(" ")}>New</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.confirmStats}>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Title match</span>
|
||||
<span className={[s.statVal, selectedMatch.similarity > 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}>
|
||||
{Math.round(selectedMatch.similarity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Chapters on new source</span>
|
||||
<span className={[s.statVal, chapterDiff < -5 ? s.statWarn : ""].join(" ").trim()}>
|
||||
{selectedMatch.chapters.length}
|
||||
{chapterDiff !== 0 && (
|
||||
<span className={s.chapterDiff}>{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Read progress to carry over</span>
|
||||
<span className={s.statVal}>{selectedMatch.readCount} / {readCount} chapters</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chapterDiff < -5 && (
|
||||
<div className={s.warnBox}>
|
||||
<Warning size={13} weight="light" />
|
||||
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={s.confirmNote}>
|
||||
The current entry will be removed from your library. Downloads are not transferred.
|
||||
</p>
|
||||
|
||||
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
||||
|
||||
<div className={s.confirmActions}>
|
||||
<button className={s.backBtn} onClick={() => setStep("search")} disabled={migrating}>
|
||||
Back
|
||||
</button>
|
||||
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
||||
{migrating
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
||||
: <><Check size={13} weight="bold" /> Migrate</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
.root {
|
||||
position: fixed; inset: 0;
|
||||
background: #000;
|
||||
display: flex; flex-direction: column;
|
||||
z-index: var(--z-reader);
|
||||
transform: translateZ(0); will-change: transform;
|
||||
}
|
||||
|
||||
/* ── UI autohide ── */
|
||||
.uiHidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.topbar, .bottombar {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
/* ── Topbar ── */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
padding: 0 var(--sp-3); height: 40px;
|
||||
background: var(--bg-void); border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0; overflow: visible;
|
||||
position: relative; z-index: 2;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||
color: var(--text-muted); flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.2; cursor: default; }
|
||||
|
||||
.chLabel {
|
||||
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
||||
font-size: var(--text-sm); color: var(--text-muted);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.chTitle { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.chSep { color: var(--text-faint); }
|
||||
|
||||
.pageLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topSep {
|
||||
width: 1px; height: 16px;
|
||||
background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.modeBtn {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
||||
color: var(--text-muted); flex-shrink: 0;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.modeBtn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.modeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.modeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.modeBtnLabel { text-transform: capitalize; }
|
||||
|
||||
/* ── Zoom ── */
|
||||
.zoomWrap {
|
||||
position: relative; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zoomBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
||||
min-width: 36px; text-align: center;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.zoomBtn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||||
|
||||
.zoomPopover {
|
||||
position: absolute; top: calc(100% + 6px); left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2);
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
z-index: 100; min-width: 160px;
|
||||
animation: scaleIn 0.1s ease both; transform-origin: top center;
|
||||
}
|
||||
|
||||
.zoomSlider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 140px; height: 3px;
|
||||
background: var(--border-strong);
|
||||
border-radius: 2px; outline: none; cursor: pointer;
|
||||
}
|
||||
.zoomSlider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.zoomSlider::-moz-range-thumb {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%; border: none;
|
||||
background: var(--accent-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoomResetBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
padding: 2px var(--sp-2); border-radius: var(--radius-sm);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.zoomResetBtn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
/* ── Viewer ── */
|
||||
.viewer {
|
||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.viewerStrip {
|
||||
justify-content: flex-start;
|
||||
padding: var(--sp-4) 0;
|
||||
}
|
||||
|
||||
/* ── Images ── */
|
||||
.img {
|
||||
display: block; user-select: none;
|
||||
image-rendering: auto;
|
||||
}
|
||||
.img.optimizeContrast { image-rendering: -webkit-optimize-contrast; }
|
||||
|
||||
/* Fit modes */
|
||||
.fitWidth { max-width: var(--max-page-width); width: 100%; height: auto; }
|
||||
.fitHeight { max-height: calc(100vh - 80px); width: auto; max-width: 100%; }
|
||||
.fitScreen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; }
|
||||
.fitOriginal { max-width: none; width: auto; height: auto; }
|
||||
|
||||
/* Longstrip */
|
||||
.stripGap { margin-bottom: 8px; }
|
||||
|
||||
/* ── Double page ── */
|
||||
.doubleWrap {
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
max-width: calc(var(--max-page-width) * 2);
|
||||
width: 100%;
|
||||
}
|
||||
.pageHalf { flex: 1; min-width: 0; object-fit: contain; }
|
||||
.gapLeft { margin-right: 2px; }
|
||||
.gapRight { margin-left: 2px; }
|
||||
|
||||
/* ── Bottom nav ── */
|
||||
.bottombar {
|
||||
display: flex; align-items: center; justify-content: center; gap: var(--sp-4);
|
||||
padding: var(--sp-3); border-top: 1px solid var(--border-dim);
|
||||
background: var(--bg-void); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 34px; height: 34px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-strong); color: var(--text-muted);
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.navBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
||||
.navBtn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
/* ── States ── */
|
||||
.center {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
position: fixed; inset: 0; background: #000;
|
||||
}
|
||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
||||
|
||||
/* ── Download modal ── */
|
||||
.dlBackdrop {
|
||||
position: fixed; inset: 0;
|
||||
z-index: calc(var(--z-reader) + 10);
|
||||
display: flex; align-items: flex-start; justify-content: flex-end;
|
||||
padding: 48px var(--sp-4) 0;
|
||||
}
|
||||
|
||||
.dlModal {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl); padding: var(--sp-3);
|
||||
min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.12s ease both; transform-origin: top right;
|
||||
}
|
||||
|
||||
.dlTitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2);
|
||||
border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.dlOption {
|
||||
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);
|
||||
}
|
||||
.dlOption:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.dlOption:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.dlSub { font-size: var(--text-xs); color: var(--text-faint); }
|
||||
|
||||
.dlRow { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
|
||||
.dlStepper {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dlStepBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 22px; height: 28px;
|
||||
font-size: var(--text-base); color: var(--text-muted);
|
||||
background: none; border: none; cursor: pointer; line-height: 1;
|
||||
transition: color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.dlStepBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.dlStepBtn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
.dlStepVal {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-secondary); min-width: 24px; text-align: center;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
/* Viewer focus — suppress outline since we're handling keys ourselves */
|
||||
.viewer:focus { outline: none; }
|
||||
@@ -1,342 +0,0 @@
|
||||
/* ── Root ────────────────────────────────────────────────────────────────── */
|
||||
.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);
|
||||
padding: var(--sp-3) var(--sp-6) var(--sp-3) 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; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Tab bar ─────────────────────────────────────────────────────────────── */
|
||||
.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); border: none;
|
||||
background: none; color: var(--text-faint); cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base); white-space: nowrap;
|
||||
}
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tabActive {
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Keyword tab bar area ────────────────────────────────────────────────── */
|
||||
.keywordBar {
|
||||
flex-shrink: 0; display: flex; flex-direction: column;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
/* ── Shared search bar ───────────────────────────────────────────────────── */
|
||||
.searchBar {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-lg); padding: 0 var(--sp-3) 0 var(--sp-2);
|
||||
transition: border-color var(--t-base);
|
||||
margin: var(--sp-3) var(--sp-6);
|
||||
}
|
||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||
|
||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
|
||||
.searchInput {
|
||||
flex: 1; background: none; border: none; outline: none;
|
||||
color: var(--text-primary); font-size: var(--text-sm); padding: 8px 0;
|
||||
}
|
||||
.searchInput::placeholder { color: var(--text-faint); }
|
||||
|
||||
.advancedBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||||
background: none; border: 1px solid transparent;
|
||||
color: var(--text-faint); cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.advancedBtn:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
||||
.advancedBtnActive {
|
||||
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.searchBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md); flex-shrink: 0;
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); cursor: pointer;
|
||||
transition: filter var(--t-base); display: flex; align-items: center; gap: var(--sp-2);
|
||||
}
|
||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.clearSearchBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
font-size: 15px; line-height: 1;
|
||||
color: var(--text-faint); background: var(--bg-overlay); border: none;
|
||||
cursor: pointer; flex-shrink: 0; transition: color var(--t-base);
|
||||
}
|
||||
.clearSearchBtn:hover { color: var(--text-muted); }
|
||||
|
||||
/* ── Advanced filter panel ───────────────────────────────────────────────── */
|
||||
.advancedPanel {
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: 0 var(--sp-6) var(--sp-4);
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
|
||||
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
|
||||
.advancedTitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.advancedActions { display: flex; gap: var(--sp-3); }
|
||||
.advancedLink {
|
||||
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;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.advancedLink:hover { opacity: 0.7; }
|
||||
|
||||
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.langFilterRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); padding: var(--sp-3) var(--sp-3) 0; }
|
||||
|
||||
.langChip {
|
||||
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);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.langChipActive {
|
||||
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.advancedDivider { height: 1px; background: var(--border-dim); margin: 0 calc(-1 * var(--sp-6)); }
|
||||
.advancedCheck {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); cursor: pointer; user-select: none;
|
||||
}
|
||||
.checkbox { accent-color: var(--accent-fg); width: 13px; height: 13px; cursor: pointer; }
|
||||
.advancedFooter {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.advancedFooter strong { color: var(--text-muted); font-weight: var(--weight-medium); }
|
||||
|
||||
/* ── Keyword results list ────────────────────────────────────────────────── */
|
||||
.results {
|
||||
flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6);
|
||||
display: flex; flex-direction: column; gap: var(--sp-6);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
|
||||
.sourceHeader { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.sourceIcon { width: 18px; height: 18px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||
.sourceName { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.sourceLang {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider); padding: 1px 5px;
|
||||
border: 1px solid var(--border-dim); border-radius: var(--radius-sm);
|
||||
}
|
||||
.resultCount {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto;
|
||||
}
|
||||
.sourceError { font-size: var(--text-xs); color: var(--color-error); padding: 0 var(--sp-1); }
|
||||
|
||||
.sourceRow {
|
||||
display: flex; gap: var(--sp-3); overflow-x: auto;
|
||||
padding-bottom: var(--sp-2); scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* ── Shared manga card ───────────────────────────────────────────────────── */
|
||||
.card {
|
||||
flex-shrink: 0; width: 110px;
|
||||
background: none; border: none; padding: 0;
|
||||
cursor: pointer; text-align: left;
|
||||
}
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative; aspect-ratio: 2/3;
|
||||
border-radius: var(--radius-md); overflow: hidden;
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
}
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
|
||||
|
||||
.inLibBadge {
|
||||
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);
|
||||
}
|
||||
.cardTitle {
|
||||
margin-top: var(--sp-1); font-size: var(--text-xs); color: var(--text-muted);
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
/* ── Skeleton cards ──────────────────────────────────────────────────────── */
|
||||
.skCard { flex-shrink: 0; width: 110px; }
|
||||
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); width: 100%; }
|
||||
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; border-radius: 3px; }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.empty {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
.emptyIcon { color: var(--text-faint); }
|
||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); text-align: center; max-width: 280px; }
|
||||
|
||||
.advancedLinkStandalone {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); background: none; border: none;
|
||||
cursor: pointer; padding: 0; margin-top: var(--sp-1);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.advancedLinkStandalone:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Split layout (tag + source tabs) ───────────────────────────────────── */
|
||||
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||
|
||||
.splitSidebar {
|
||||
width: 192px; flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-dim);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.splitSearchWrap {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
.splitSearchInput {
|
||||
flex: 1; background: none; border: none; outline: none;
|
||||
color: var(--text-primary); font-size: var(--text-xs);
|
||||
font-family: var(--font-ui); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.splitSearchInput::placeholder { color: var(--text-faint); }
|
||||
|
||||
.splitList {
|
||||
flex: 1; overflow-y: auto; padding: var(--sp-2);
|
||||
display: flex; flex-direction: column; gap: 1px; scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.splitItem {
|
||||
display: flex; align-items: center; width: 100%;
|
||||
padding: 7px var(--sp-2); border-radius: var(--radius-md);
|
||||
border: none; background: none;
|
||||
color: var(--text-muted); font-size: var(--text-sm);
|
||||
cursor: pointer; text-align: left;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.splitItem:hover { background: var(--bg-overlay); color: var(--text-secondary); }
|
||||
.splitItemActive { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.splitItemActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.splitItemSource { gap: var(--sp-2); }
|
||||
.splitItemLabel { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.splitSourceIcon { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; flex-shrink: 0; }
|
||||
|
||||
.splitEmpty {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); padding: var(--sp-3) var(--sp-2);
|
||||
}
|
||||
.splitLoading {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Split right content ─────────────────────────────────────────────────── */
|
||||
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
.splitContentHeader {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
min-height: 52px;
|
||||
}
|
||||
.splitContentTitle {
|
||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary); letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.splitResultCount {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
margin-left: auto;
|
||||
}
|
||||
.splitSourceTitle {
|
||||
display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0;
|
||||
}
|
||||
.sourceBrowseBar {
|
||||
display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Grid (tag + source results) ─────────────────────────────────────────── */
|
||||
.tagGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 9vw, 120px), 1fr));
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
overflow-y: auto; flex: 1;
|
||||
align-content: start;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
/* In the grid, cards stretch to fill the column */
|
||||
.tagGrid .card { width: auto; }
|
||||
.tagGrid .skCard { width: auto; }
|
||||
.tagGrid .skCover { width: 100%; }
|
||||
|
||||
/* ── Show more (tag grid & genre drill) ──────────────────────────────────── */
|
||||
.showMoreCell {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-2) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
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: 7px 20px; border-radius: var(--radius-md);
|
||||
background: var(--bg-raised); color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim); cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.showMoreBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.showMoreBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
.nsfwBadge {
|
||||
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);
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
}
|
||||
@@ -1,799 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect, memo, useMemo } from "react";
|
||||
import {
|
||||
MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List,
|
||||
} from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import s from "./Search.module.css";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type SearchTab = "keyword" | "tag" | "source";
|
||||
|
||||
interface SourceResult {
|
||||
source: Source;
|
||||
mangas: Manga[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CONCURRENCY = 4;
|
||||
const RESULTS_PER_SOURCE = 8;
|
||||
|
||||
const COMMON_GENRES = [
|
||||
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
|
||||
"Sci-Fi","Slice of Life","Horror","Mystery","Thriller","Sports",
|
||||
"Supernatural","Mecha","Historical","Psychological","School Life",
|
||||
"Shounen","Seinen","Josei","Shoujo","Isekai","Martial Arts",
|
||||
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
|
||||
];
|
||||
|
||||
// ── Concurrent fetch helper ───────────────────────────────────────────────────
|
||||
|
||||
async function runConcurrent<T>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < items.length) {
|
||||
if (signal.aborted) return;
|
||||
const item = items[i++];
|
||||
await fn(item).catch(() => {});
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||
}
|
||||
|
||||
// ── Shared card ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CoverImg = memo(function CoverImg({
|
||||
src, alt, className,
|
||||
}: { src: string; alt: string; className?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async" onLoad={() => setLoaded(true)}
|
||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function MangaCard({ manga, onClick }: { manga: Manga; onClick: () => void }) {
|
||||
return (
|
||||
<button className={s.card} onClick={onClick}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||
{manga.inLibrary && <span className={s.inLibBadge}>Saved</span>}
|
||||
</div>
|
||||
<p className={s.cardTitle}>{manga.title}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function GridSkeleton({ count = 18 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.tagGrid}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.skCard} style={{ width: "auto" }}>
|
||||
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
|
||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.sourceRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.skCard}>
|
||||
<div className={["skeleton", s.skCover].join(" ")} />
|
||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Root ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Search() {
|
||||
const [tab, setTab] = useState<SearchTab>("keyword");
|
||||
|
||||
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
|
||||
const searchPrefill = useStore((st) => st.searchPrefill ?? "");
|
||||
const setSearchPrefill = useStore((st) => st.setSearchPrefill);
|
||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||
|
||||
const [allSources, setAllSources] = useState<Source[]>([]);
|
||||
const [loadingSources, setLoadingSources] = useState(false);
|
||||
|
||||
const pendingPrefill = useRef<string>("");
|
||||
|
||||
// Consume searchPrefill → route to keyword tab
|
||||
useEffect(() => {
|
||||
if (!searchPrefill) return;
|
||||
pendingPrefill.current = searchPrefill;
|
||||
setTab("keyword");
|
||||
setSearchPrefill("");
|
||||
}, [searchPrefill, setSearchPrefill]);
|
||||
|
||||
// Load sources once, shared across all tabs
|
||||
useEffect(() => {
|
||||
setLoadingSources(true);
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => d.sources.nodes.filter((src) => src.id !== "0"))
|
||||
)
|
||||
.then(setAllSources)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingSources(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const availableLangs = useMemo(() =>
|
||||
Array.from(new Set<string>(allSources.map((s) => s.lang))).sort(), [allSources]);
|
||||
const hasMultipleLangs = availableLangs.length > 1;
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Search</h1>
|
||||
<div className={s.tabs}>
|
||||
<button className={[s.tab, tab === "keyword" ? s.tabActive : ""].join(" ")} onClick={() => setTab("keyword")}>
|
||||
<MagnifyingGlass size={11} weight="bold" /> Keyword
|
||||
</button>
|
||||
<button className={[s.tab, tab === "tag" ? s.tabActive : ""].join(" ")} onClick={() => setTab("tag")}>
|
||||
<Hash size={11} weight="bold" /> Tags
|
||||
</button>
|
||||
<button className={[s.tab, tab === "source" ? s.tabActive : ""].join(" ")} onClick={() => setTab("source")}>
|
||||
<List size={11} weight="bold" /> Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "keyword" && (
|
||||
<KeywordTab
|
||||
allSources={allSources}
|
||||
loadingSources={loadingSources}
|
||||
availableLangs={availableLangs}
|
||||
hasMultipleLangs={hasMultipleLangs}
|
||||
preferredLang={preferredLang}
|
||||
pendingPrefill={pendingPrefill}
|
||||
onMangaClick={setPreviewManga}
|
||||
/>
|
||||
)}
|
||||
{tab === "tag" && (
|
||||
<TagTab
|
||||
allSources={allSources}
|
||||
loadingSources={loadingSources}
|
||||
preferredLang={preferredLang}
|
||||
onMangaClick={setPreviewManga}
|
||||
/>
|
||||
)}
|
||||
{tab === "source" && (
|
||||
<SourceTab
|
||||
allSources={allSources}
|
||||
loadingSources={loadingSources}
|
||||
availableLangs={availableLangs}
|
||||
hasMultipleLangs={hasMultipleLangs}
|
||||
onMangaClick={setPreviewManga}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Keyword tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function KeywordTab({
|
||||
allSources, loadingSources, availableLangs, hasMultipleLangs,
|
||||
preferredLang, pendingPrefill, onMangaClick,
|
||||
}: {
|
||||
allSources: Source[];
|
||||
loadingSources: boolean;
|
||||
availableLangs: string[];
|
||||
hasMultipleLangs: boolean;
|
||||
preferredLang: string;
|
||||
pendingPrefill: React.MutableRefObject<string>;
|
||||
onMangaClick: (m: Manga) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [submitted, setSubmitted] = useState("");
|
||||
const [results, setResults] = useState<SourceResult[]>([]);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [selectedLangs, setSelectedLangs] = useState<Set<string>>(new Set());
|
||||
const [includeNsfw, setIncludeNsfw] = useState(false);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const allSourcesRef = useRef<Source[]>([]);
|
||||
const selectedLangsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => { allSourcesRef.current = allSources; }, [allSources]);
|
||||
useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]);
|
||||
|
||||
// Set default lang selection once sources load
|
||||
useEffect(() => {
|
||||
if (!allSources.length) return;
|
||||
const available = new Set(allSources.map((s) => s.lang));
|
||||
setSelectedLangs(available.has(preferredLang)
|
||||
? new Set([preferredLang])
|
||||
: new Set(availableLangs.slice(0, 1))
|
||||
);
|
||||
}, [allSources]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Consume prefill once sources are ready
|
||||
useEffect(() => {
|
||||
if (loadingSources || !pendingPrefill.current || submitted) return;
|
||||
if (!allSourcesRef.current.length) return;
|
||||
const q = pendingPrefill.current;
|
||||
pendingPrefill.current = "";
|
||||
setQuery(q);
|
||||
doSearch(q);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadingSources]);
|
||||
|
||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||
|
||||
const getVisibleSources = useCallback((): Source[] => {
|
||||
let filtered = allSourcesRef.current;
|
||||
if (selectedLangsRef.current.size > 0)
|
||||
filtered = filtered.filter((s) => selectedLangsRef.current.has(s.lang));
|
||||
if (!includeNsfw)
|
||||
filtered = filtered.filter((s) => !s.isNsfw);
|
||||
return filtered;
|
||||
}, [includeNsfw]);
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) return;
|
||||
const visible = getVisibleSources();
|
||||
if (!visible.length) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
setSubmitted(trimmed);
|
||||
setResults(visible.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
|
||||
|
||||
await runConcurrent(visible, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
|
||||
ctrl.signal,
|
||||
);
|
||||
if (ctrl.signal.aborted) return;
|
||||
setResults((prev) => prev.map((r) =>
|
||||
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
|
||||
));
|
||||
} catch (e: any) {
|
||||
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||
setResults((prev) => prev.map((r) =>
|
||||
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
|
||||
));
|
||||
}
|
||||
}, ctrl.signal);
|
||||
}, [getVisibleSources]);
|
||||
|
||||
function toggleLang(lang: string) {
|
||||
setSelectedLangs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(lang)) { if (next.size === 1) return prev; next.delete(lang); }
|
||||
else next.add(lang);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const visibleCount = getVisibleSources().length;
|
||||
const hasResults = results.some((r) => r.mangas.length > 0);
|
||||
const allDone = results.every((r) => !r.loading);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={s.keywordBar}>
|
||||
<div className={s.searchBar}>
|
||||
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
|
||||
<input
|
||||
ref={inputRef} autoFocus
|
||||
className={s.searchInput}
|
||||
placeholder="Search across sources…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && doSearch(query)}
|
||||
/>
|
||||
{hasMultipleLangs && (
|
||||
<button
|
||||
className={[s.advancedBtn, showAdvanced ? s.advancedBtnActive : ""].join(" ")}
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
title="Language & filter options"
|
||||
>
|
||||
<SlidersHorizontal size={13} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={s.searchBtn}
|
||||
onClick={() => doSearch(query)}
|
||||
disabled={!query.trim() || loadingSources}
|
||||
>
|
||||
{loadingSources
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasMultipleLangs && showAdvanced && (
|
||||
<div className={s.advancedPanel}>
|
||||
<div className={s.advancedHeader}>
|
||||
<span className={s.advancedTitle}>Languages</span>
|
||||
<div className={s.advancedActions}>
|
||||
<button className={s.advancedLink} onClick={() => setSelectedLangs(new Set(availableLangs))}>All</button>
|
||||
<button className={s.advancedLink} onClick={() => setSelectedLangs(new Set([preferredLang]))}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.langGrid}>
|
||||
{availableLangs.map((lang) => (
|
||||
<button key={lang}
|
||||
className={[s.langChip, selectedLangs.has(lang) ? s.langChipActive : ""].join(" ")}
|
||||
onClick={() => toggleLang(lang)}
|
||||
>
|
||||
{lang === preferredLang ? `${lang.toUpperCase()} ★` : lang.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={s.advancedDivider} />
|
||||
<label className={s.advancedCheck}>
|
||||
<input type="checkbox" checked={includeNsfw}
|
||||
onChange={(e) => setIncludeNsfw(e.target.checked)} className={s.checkbox} />
|
||||
Include NSFW sources
|
||||
</label>
|
||||
<div className={s.advancedFooter}>
|
||||
Searching <strong>{visibleCount}</strong> source{visibleCount !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!submitted && (
|
||||
<div className={s.empty}>
|
||||
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>Search across sources</p>
|
||||
<p className={s.emptyHint}>
|
||||
{hasMultipleLangs
|
||||
? `${visibleCount} source${visibleCount !== 1 ? "s" : ""} · ${selectedLangs.size} language${selectedLangs.size !== 1 ? "s" : ""}`
|
||||
: `${visibleCount} source${visibleCount !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
{hasMultipleLangs && !showAdvanced && (
|
||||
<button className={s.advancedLinkStandalone} onClick={() => setShowAdvanced(true)}>
|
||||
<SlidersHorizontal size={12} weight="light" /> Adjust language filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitted && (
|
||||
<div className={s.results}>
|
||||
{results.length === 0 && (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
)}
|
||||
{results
|
||||
.filter((r) => r.mangas.length > 0 || r.loading || r.error)
|
||||
.map(({ source, mangas, loading, error }) => (
|
||||
<div key={source.id} className={s.sourceSection}>
|
||||
<div className={s.sourceHeader}>
|
||||
<img src={thumbUrl(source.iconUrl)} alt={source.displayName} className={s.sourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.sourceName}>{source.displayName}</span>
|
||||
{hasMultipleLangs && <span className={s.sourceLang}>{source.lang.toUpperCase()}</span>}
|
||||
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
|
||||
{!loading && mangas.length > 0 && <span className={s.resultCount}>{mangas.length} results</span>}
|
||||
</div>
|
||||
{error ? (
|
||||
<p className={s.sourceError}>{error}</p>
|
||||
) : loading ? (
|
||||
<RowSkeleton />
|
||||
) : mangas.length > 0 ? (
|
||||
<div className={s.sourceRow}>
|
||||
{mangas.slice(0, RESULTS_PER_SOURCE).map((m) => (
|
||||
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{allDone && !hasResults && (
|
||||
<div className={s.empty}>
|
||||
<p className={s.emptyText}>No results for "{submitted}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tag tab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const TAG_PAGE_SIZE = 50; // items shown per "page"
|
||||
const TAG_FETCH_PAGES = 3; // source pages to fetch per source on initial load
|
||||
const TAG_MAX_SOURCES = 12; // max sources to query
|
||||
|
||||
function TagTab({
|
||||
preferredLang, onMangaClick,
|
||||
}: {
|
||||
allSources: Source[];
|
||||
loadingSources: boolean;
|
||||
preferredLang: string;
|
||||
onMangaClick: (m: Manga) => void;
|
||||
}) {
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||
const [tagResults, setTagResults] = useState<Manga[]>([]);
|
||||
const [loadingTag, setLoadingTag] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(TAG_PAGE_SIZE);
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
// Track next page to fetch per source for "load more from network"
|
||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||
const sourcesRef = useRef<Source[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||
|
||||
async function drillTag(tag: string) {
|
||||
if (tag === activeTag && !loadingTag) return;
|
||||
setActiveTag(tag);
|
||||
setTagResults([]);
|
||||
setLoadingTag(true);
|
||||
setVisibleCount(TAG_PAGE_SIZE);
|
||||
nextPageRef.current = new Map();
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
const sources = await cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => d.sources.nodes.filter((s) => s.id !== "0"))
|
||||
);
|
||||
const deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES);
|
||||
sourcesRef.current = deduped;
|
||||
|
||||
// Start all at -1; the fetch loop sets the real next page if hasNextPage is true
|
||||
for (const src of deduped) {
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
}
|
||||
|
||||
// Stream results in: fetch each source's pages concurrently, update state as each settles
|
||||
await runConcurrent(deduped, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageResults: Manga[] = [];
|
||||
// Fetch TAG_FETCH_PAGES pages in series per source
|
||||
for (let page = 1; page <= TAG_FETCH_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: tag },
|
||||
ctrl.signal,
|
||||
);
|
||||
pageResults.push(...d.fetchSourceManga.mangas);
|
||||
if (!d.fetchSourceManga.hasNextPage) {
|
||||
nextPageRef.current.set(src.id, -1); // no more pages
|
||||
break;
|
||||
} else if (page === TAG_FETCH_PAGES) {
|
||||
// Still has more pages beyond what we fetched upfront
|
||||
nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
break; // source error — move on
|
||||
}
|
||||
}
|
||||
if (!ctrl.signal.aborted && pageResults.length > 0) {
|
||||
setTagResults((prev) => dedupeMangaById([...prev, ...pageResults]));
|
||||
}
|
||||
}, ctrl.signal);
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) setLoadingTag(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!activeTag || loadingMore) return;
|
||||
|
||||
// First check if we have more buffered results to show
|
||||
if (visibleCount < tagResults.length) {
|
||||
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise fetch next pages from sources
|
||||
const sourcesToFetch = sourcesRef.current.filter(
|
||||
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
||||
);
|
||||
if (sourcesToFetch.length === 0) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
await runConcurrent(sourcesToFetch, async (src) => {
|
||||
const page = nextPageRef.current.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: activeTag },
|
||||
ctrl.signal,
|
||||
);
|
||||
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
||||
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) {
|
||||
setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filteredGenres = useMemo(() => {
|
||||
const q = tagFilter.trim().toLowerCase();
|
||||
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
||||
}, [tagFilter]);
|
||||
|
||||
const visibleResults = tagResults.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < tagResults.length ||
|
||||
sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||
|
||||
return (
|
||||
<div className={s.splitRoot}>
|
||||
<div className={s.splitSidebar}>
|
||||
<div className={s.splitSearchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" />
|
||||
<input
|
||||
className={s.splitSearchInput}
|
||||
placeholder="Filter tags…"
|
||||
value={tagFilter}
|
||||
onChange={(e) => setTagFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.splitList}>
|
||||
{filteredGenres.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
className={[s.splitItem, activeTag === tag ? s.splitItemActive : ""].join(" ")}
|
||||
onClick={() => drillTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{filteredGenres.length === 0 && <p className={s.splitEmpty}>No matching tags</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.splitContent}>
|
||||
{!activeTag ? (
|
||||
<div className={s.empty}>
|
||||
<Hash size={32} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>Browse by tag</p>
|
||||
<p className={s.emptyHint}>Select a genre tag to see matching manga across your sources.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={s.splitContentHeader}>
|
||||
<span className={s.splitContentTitle}>{activeTag}</span>
|
||||
{loadingTag
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
: <span className={s.splitResultCount}>
|
||||
{visibleResults.length}{tagResults.length > visibleCount ? `+` : ""} of {tagResults.length} results
|
||||
</span>}
|
||||
</div>
|
||||
{loadingTag ? (
|
||||
<GridSkeleton count={50} />
|
||||
) : tagResults.length > 0 ? (
|
||||
<div className={s.tagGrid}>
|
||||
{visibleResults.map((m) => (
|
||||
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className={s.showMoreCell}>
|
||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading…</>
|
||||
: "Show more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.empty}>
|
||||
<p className={s.emptyText}>No results for "{activeTag}"</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Source tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function SourceTab({
|
||||
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
|
||||
}: {
|
||||
allSources: Source[];
|
||||
loadingSources: boolean;
|
||||
availableLangs: string[];
|
||||
hasMultipleLangs: boolean;
|
||||
onMangaClick: (m: Manga) => void;
|
||||
}) {
|
||||
const [selectedLang, setSelectedLang] = useState<string>("all");
|
||||
const [activeSource, setActiveSource] = useState<Source | null>(null);
|
||||
const [browseResults, setBrowseResults] = useState<Manga[]>([]);
|
||||
const [loadingBrowse, setLoadingBrowse] = useState(false);
|
||||
const [browseQuery, setBrowseQuery] = useState("");
|
||||
const [submitted, setSubmitted] = useState("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||
|
||||
const visibleSources = useMemo(() =>
|
||||
selectedLang === "all" ? allSources : allSources.filter((s) => s.lang === selectedLang),
|
||||
[allSources, selectedLang]
|
||||
);
|
||||
|
||||
async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string) {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoadingBrowse(true);
|
||||
setBrowseResults([]);
|
||||
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type, page: 1, query: q ?? null },
|
||||
ctrl.signal,
|
||||
);
|
||||
if (!ctrl.signal.aborted) setBrowseResults(d.fetchSourceManga.mangas);
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) setLoadingBrowse(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectSource(src: Source) {
|
||||
setActiveSource(src);
|
||||
setBrowseQuery("");
|
||||
setSubmitted("");
|
||||
fetchBrowse(src, "POPULAR");
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (!activeSource || !browseQuery.trim()) return;
|
||||
setSubmitted(browseQuery.trim());
|
||||
fetchBrowse(activeSource, "SEARCH", browseQuery.trim());
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
setBrowseQuery("");
|
||||
setSubmitted("");
|
||||
if (activeSource) fetchBrowse(activeSource, "POPULAR");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.splitRoot}>
|
||||
<div className={s.splitSidebar}>
|
||||
{hasMultipleLangs && (
|
||||
<div className={s.langFilterRow}>
|
||||
{["all", ...availableLangs].map((lang) => (
|
||||
<button key={lang}
|
||||
className={[s.langChip, selectedLang === lang ? s.langChipActive : ""].join(" ")}
|
||||
onClick={() => setSelectedLang(lang)}
|
||||
>
|
||||
{lang === "all" ? "All" : lang.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{loadingSources ? (
|
||||
<div className={s.splitLoading}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.splitList}>
|
||||
{visibleSources.map((src) => (
|
||||
<button key={src.id}
|
||||
className={[s.splitItem, s.splitItemSource, activeSource?.id === src.id ? s.splitItemActive : ""].join(" ")}
|
||||
onClick={() => selectSource(src)}
|
||||
>
|
||||
<img src={thumbUrl(src.iconUrl)} alt="" className={s.splitSourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.splitItemLabel}>{src.displayName}</span>
|
||||
{src.isNsfw && <span className={s.nsfwBadge}>18+</span>}
|
||||
</button>
|
||||
))}
|
||||
{visibleSources.length === 0 && <p className={s.splitEmpty}>No sources for this language</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.splitContent}>
|
||||
{!activeSource ? (
|
||||
<div className={s.empty}>
|
||||
<List size={32} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>Browse a source</p>
|
||||
<p className={s.emptyHint}>Select a source to see its popular titles, or search within it.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={s.splitContentHeader}>
|
||||
<div className={s.splitSourceTitle}>
|
||||
<img src={thumbUrl(activeSource.iconUrl)} alt="" className={s.splitSourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.splitContentTitle}>{activeSource.displayName}</span>
|
||||
{loadingBrowse && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
||||
{!loadingBrowse && browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>}
|
||||
</div>
|
||||
<div className={s.sourceBrowseBar}>
|
||||
<div className={s.searchBar} style={{ flex: 1 }}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input
|
||||
className={s.searchInput}
|
||||
placeholder={`Search ${activeSource.displayName}…`}
|
||||
value={browseQuery}
|
||||
onChange={(e) => setBrowseQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
{submitted && (
|
||||
<button className={s.clearSearchBtn} onClick={clearSearch} title="Clear search">×</button>
|
||||
)}
|
||||
</div>
|
||||
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingBrowse ? <GridSkeleton /> : browseResults.length > 0 ? (
|
||||
<div className={s.tagGrid}>
|
||||
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.empty}>
|
||||
<p className={s.emptyText}>{submitted ? `No results for "${submitted}"` : "No results"}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
<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,705 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick, untrack } from "svelte";
|
||||
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte";
|
||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
||||
import type { FitMode } from "../../store/state.svelte";
|
||||
|
||||
const pageCache = new Map<number, string[]>();
|
||||
const inflight = new Map<number, Promise<string[]>>();
|
||||
const cacheOrder: number[] = [];
|
||||
const MAX_CACHED = 10;
|
||||
|
||||
function cacheTouch(id: number) {
|
||||
const i = cacheOrder.indexOf(id);
|
||||
if (i !== -1) cacheOrder.splice(i, 1);
|
||||
cacheOrder.push(id);
|
||||
}
|
||||
|
||||
function cacheEvict(keep: Set<number>) {
|
||||
while (pageCache.size > MAX_CACHED) {
|
||||
const victim = cacheOrder.find(id => !keep.has(id));
|
||||
if (!victim) break;
|
||||
cacheOrder.splice(cacheOrder.indexOf(victim), 1);
|
||||
pageCache.delete(victim);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
|
||||
const cached = pageCache.get(chapterId);
|
||||
if (cached) { cacheTouch(chapterId); return Promise.resolve(cached); }
|
||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
if (!inflight.has(chapterId)) {
|
||||
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||||
.then(d => { const urls = d.fetchChapterPages.pages.map(thumbUrl); pageCache.set(chapterId, urls); cacheTouch(chapterId); return urls; })
|
||||
.finally(() => inflight.delete(chapterId));
|
||||
inflight.set(chapterId, p);
|
||||
}
|
||||
const base = inflight.get(chapterId)!;
|
||||
if (!signal) return base;
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||
base.then(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
const aspectCache = new Map<string, number>();
|
||||
function preloadImage(url: string) { new Image().src = url; }
|
||||
|
||||
function decodeImage(url: string): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); };
|
||||
img.onerror = () => resolve();
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function measureAspect(url: string): Promise<number> {
|
||||
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
||||
return new Promise(res => {
|
||||
const img = new Image();
|
||||
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
|
||||
img.onerror = () => res(0.67);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; startGlobalIdx: number; }
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let sentinelEl: HTMLDivElement;
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
let dlOpen = $state(false);
|
||||
let zoomOpen = $state(false);
|
||||
let uiVisible = $state(true);
|
||||
let pageReady = $state(false);
|
||||
let pageGroups: number[][] = $state([]);
|
||||
let stripChapters: StripChapter[] = $state([]);
|
||||
let visibleChapterId: number | null = $state(null);
|
||||
let nextN = $state(5);
|
||||
let dlBusy = $state(false);
|
||||
let markedRead = new Set<number>();
|
||||
let appended = new Set<number>();
|
||||
let appending = false;
|
||||
let abortCtrl: AbortController | null = null;
|
||||
let loadingId: number | null = null;
|
||||
let scrollAnchor: { scrollTop: number; scrollHeight: number } | null = null;
|
||||
|
||||
|
||||
const rtl = $derived(store.settings.readingDirection === "rtl");
|
||||
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
||||
const style = $derived(store.settings.pageStyle ?? "single");
|
||||
const maxW = $derived(store.settings.maxPageWidth ?? 900);
|
||||
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
||||
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
||||
const lastPage = $derived(store.pageUrls.length);
|
||||
|
||||
const displayChapter = $derived((style === "longstrip" && autoNext && visibleChapterId)
|
||||
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
||||
: store.activeChapter);
|
||||
|
||||
const adjacent = $derived((() => {
|
||||
const ref = displayChapter ?? store.activeChapter;
|
||||
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
||||
const idx = store.activeChapterList.findIndex(c => c.id === ref.id);
|
||||
return {
|
||||
prev: idx > 0 ? store.activeChapterList[idx - 1] : null,
|
||||
next: idx < store.activeChapterList.length - 1 ? store.activeChapterList[idx + 1] : null,
|
||||
remaining: store.activeChapterList.slice(idx + 1),
|
||||
};
|
||||
})());
|
||||
|
||||
const visibleChunkLastPage = $derived((() => {
|
||||
if (style !== "longstrip" || !autoNext) return lastPage;
|
||||
const chId = visibleChapterId ?? store.activeChapter?.id;
|
||||
const chunk = stripChapters.find(c => c.chapterId === chId);
|
||||
return chunk?.urls.length ?? lastPage;
|
||||
})());
|
||||
|
||||
const imgCls = $derived([
|
||||
"img",
|
||||
fit === "width" && "fit-width",
|
||||
fit === "height" && "fit-height",
|
||||
fit === "screen" && "fit-screen",
|
||||
fit === "original" && "fit-original",
|
||||
store.settings.optimizeContrast && "optimize-contrast",
|
||||
].filter(Boolean).join(" "));
|
||||
|
||||
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
|
||||
const styleLabel = $derived(style);
|
||||
|
||||
function maybeMarkCurrentRead() {
|
||||
const ch = store.activeChapter;
|
||||
if (!ch || !markOnNext || markedRead.has(ch.id)) return;
|
||||
markedRead.add(ch.id);
|
||||
gql(MARK_CHAPTER_READ, { id: ch.id, isRead: true })
|
||||
.then(() => {
|
||||
if (store.activeManga) {
|
||||
const updated = store.activeChapterList.map(c => c.id === ch.id ? { ...c, isRead: true } : c);
|
||||
checkAndMarkCompleted(store.activeManga.id, updated);
|
||||
}
|
||||
})
|
||||
.catch(e => { markedRead.delete(ch.id); console.error(e); });
|
||||
}
|
||||
|
||||
function showUi() {
|
||||
uiVisible = true;
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => uiVisible = false, 3000);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const ch = store.activeChapter;
|
||||
if (ch) untrack(() => loadChapter(ch.id));
|
||||
});
|
||||
|
||||
async function loadChapter(id: number) {
|
||||
abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
loadingId = id;
|
||||
appended = new Set([id]);
|
||||
appending = false;
|
||||
markedRead = new Set();
|
||||
aspectCache.clear();
|
||||
loading = true;
|
||||
error = null;
|
||||
pageGroups = [];
|
||||
pageReady = false;
|
||||
stripChapters = [];
|
||||
visibleChapterId = null;
|
||||
store.pageUrls = [];
|
||||
store.pageNumber = 1;
|
||||
try {
|
||||
const urls = await fetchPages(id, ctrl.signal);
|
||||
if (ctrl.signal.aborted) return;
|
||||
store.pageUrls = urls;
|
||||
pageReady = true;
|
||||
if (style === "longstrip" && autoNext) {
|
||||
stripChapters = [{ chapterId: id, chapterName: store.activeChapter?.name ?? "", urls, startGlobalIdx: 0 }];
|
||||
visibleChapterId = id;
|
||||
}
|
||||
loading = false;
|
||||
} catch (e: any) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function appendNextChapter() {
|
||||
if (appending) return;
|
||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
||||
if (!lastChunk) return;
|
||||
const list = store.activeChapterList;
|
||||
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
|
||||
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
|
||||
const next = list[lastIdx + 1];
|
||||
if (!next || appended.has(next.id)) return;
|
||||
appended.add(next.id);
|
||||
appending = true;
|
||||
fetchPages(next.id)
|
||||
.then(urls => { urls.forEach(url => measureAspect(url).catch(() => {})); urls.slice(0, 6).forEach(preloadImage); return urls; })
|
||||
.then(urls => {
|
||||
if (stripChapters.some(c => c.chapterId === next.id)) return;
|
||||
const last = stripChapters[stripChapters.length - 1];
|
||||
const start = last ? last.startGlobalIdx + last.urls.length : 0;
|
||||
const MAX_STRIP = 8;
|
||||
if (stripChapters.length >= MAX_STRIP && containerEl) {
|
||||
scrollAnchor = { scrollTop: containerEl.scrollTop, scrollHeight: containerEl.scrollHeight };
|
||||
stripChapters = [...stripChapters.slice(1), { chapterId: next.id, chapterName: next.name, urls, startGlobalIdx: start }];
|
||||
tick().then(() => {
|
||||
if (!scrollAnchor || !containerEl) return;
|
||||
const gained = containerEl.scrollHeight - scrollAnchor.scrollHeight;
|
||||
if (gained < 0) containerEl.scrollTop = Math.max(0, scrollAnchor.scrollTop + gained);
|
||||
scrollAnchor = null;
|
||||
});
|
||||
} else {
|
||||
stripChapters = [...stripChapters, { chapterId: next.id, chapterName: next.name, urls, startGlobalIdx: start }];
|
||||
}
|
||||
appending = false;
|
||||
})
|
||||
.catch(() => { appending = false; });
|
||||
}
|
||||
|
||||
function setupScrollTracking() {
|
||||
if (!containerEl || style !== "longstrip") return;
|
||||
const READ_LINE_PCT = 0.20;
|
||||
function onScroll() {
|
||||
const containerTop = containerEl.getBoundingClientRect().top;
|
||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||
let activeLocalPage: number | null = null;
|
||||
let activeChId: number | null = null;
|
||||
for (const img of imgs) {
|
||||
const rect = img.getBoundingClientRect();
|
||||
if (rect.top <= readLineY) { activeLocalPage = Number(img.dataset.localPage); activeChId = Number(img.dataset.chapter); }
|
||||
else break;
|
||||
}
|
||||
if (activeLocalPage === null && imgs.length > 0) { activeLocalPage = Number(imgs[0].dataset.localPage); activeChId = Number(imgs[0].dataset.chapter); }
|
||||
if (activeLocalPage !== null) store.pageNumber = activeLocalPage;
|
||||
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
|
||||
if (store.settings.autoMarkRead && activeLocalPage !== null && activeChId) {
|
||||
const chunk = stripChapters.find(c => c.chapterId === activeChId);
|
||||
const total = chunk ? chunk.urls.length : store.pageUrls.length;
|
||||
if (total > 0 && activeLocalPage >= total - 1 && !markedRead.has(activeChId)) {
|
||||
markedRead.add(activeChId);
|
||||
const chIdSnap = activeChId;
|
||||
gql(MARK_CHAPTER_READ, { id: chIdSnap, isRead: true })
|
||||
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === chIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
||||
.catch(e => { markedRead.delete(chIdSnap); console.error(e); });
|
||||
}
|
||||
}
|
||||
if (containerEl.scrollTop + containerEl.clientHeight < containerEl.scrollHeight - 40) return;
|
||||
const last = stripChapters[stripChapters.length - 1];
|
||||
if (last && store.settings.autoMarkRead && !markedRead.has(last.chapterId)) {
|
||||
markedRead.add(last.chapterId);
|
||||
const lastIdSnap = last.chapterId;
|
||||
gql(MARK_CHAPTER_READ, { id: lastIdSnap, isRead: true })
|
||||
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === lastIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
function onScroll80() {
|
||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||
if (pct >= 0.8) appendNextChapter();
|
||||
}
|
||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||||
if (autoNext) containerEl.addEventListener("scroll", onScroll80, { passive: true });
|
||||
onScroll();
|
||||
return () => { containerEl.removeEventListener("scroll", onScroll); containerEl.removeEventListener("scroll", onScroll80); };
|
||||
}
|
||||
|
||||
function advanceGroup(forward: boolean) {
|
||||
if (!pageGroups.length) return;
|
||||
const gi = pageGroups.findIndex(g => g.includes(store.pageNumber));
|
||||
if (forward) {
|
||||
if (gi < pageGroups.length - 1) store.pageNumber = pageGroups[gi + 1][0];
|
||||
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
||||
else closeReader();
|
||||
} else {
|
||||
if (gi > 0) store.pageNumber = pageGroups[gi - 1][0];
|
||||
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
||||
}
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
if (loading) return;
|
||||
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } return; }
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||
if (!store.pageUrls.length) return;
|
||||
if (store.pageNumber < lastPage) { decodeImage(store.pageUrls[store.pageNumber]).then(() => store.pageNumber++); }
|
||||
else if (adjacent.next) { maybeMarkCurrentRead(); store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
||||
else closeReader();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (loading) return;
|
||||
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList); return; }
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||
if (!store.pageUrls.length) return;
|
||||
if (store.pageNumber > 1) { decodeImage(store.pageUrls[store.pageNumber - 2]).then(() => store.pageNumber--); }
|
||||
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
||||
}
|
||||
|
||||
const goNext = $derived(rtl ? goBack : goForward);
|
||||
const goPrev = $derived(rtl ? goForward : goBack);
|
||||
|
||||
function cycleStyle() {
|
||||
const opts = ["single", "longstrip"] as const;
|
||||
const cur = style === "double" ? "single" : style;
|
||||
updateSettings({ pageStyle: opts[(opts.indexOf(cur as typeof opts[number]) + 1) % opts.length] });
|
||||
}
|
||||
|
||||
function cycleFit() {
|
||||
const opts: FitMode[] = ["width", "height", "screen", "original"];
|
||||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (store.activeChapter && lastPage && store.activeManga) {
|
||||
const chapterId = store.activeChapter.id;
|
||||
const chapterName = store.activeChapter.name;
|
||||
const mangaId = store.activeManga.id;
|
||||
const mangaTitle = store.activeManga.title;
|
||||
const thumbUrl = store.activeManga.thumbnailUrl;
|
||||
const pageNum = store.pageNumber;
|
||||
const atLast = store.pageNumber === lastPage;
|
||||
untrack(() => {
|
||||
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumbUrl, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
|
||||
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) {
|
||||
if (!markedRead.has(chapterId)) {
|
||||
markedRead.add(chapterId);
|
||||
gql(MARK_CHAPTER_READ, { id: chapterId, isRead: true })
|
||||
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === chapterId ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (style === "double" && store.pageUrls.length) {
|
||||
let cancelled = false;
|
||||
const snap = store.pageUrls;
|
||||
Promise.all(snap.map(measureAspect)).then(aspects => {
|
||||
if (cancelled || snap !== store.pageUrls) return;
|
||||
const offset = store.settings.offsetDoubleSpreads;
|
||||
const groups: number[][] = [[1]];
|
||||
if (offset) groups.push([2]);
|
||||
let i = offset ? 3 : 2;
|
||||
while (i <= snap.length) {
|
||||
const a = aspects[i - 1], nextA = aspects[i] ?? 0;
|
||||
if (a > 1.2 || i === snap.length || nextA > 1.2) { groups.push([i++]); }
|
||||
else { groups.push(rtl ? [i + 1, i] : [i, i + 1]); i += 2; }
|
||||
}
|
||||
pageGroups = groups;
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
} else { pageGroups = []; }
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const ahead = store.settings.preloadPages ?? 3;
|
||||
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) decodeImage(url); }
|
||||
const behind = store.pageUrls[store.pageNumber - 2];
|
||||
if (behind) preloadImage(behind);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (store.activeChapter && store.activeChapterList.length) {
|
||||
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
|
||||
if (idx >= 0) {
|
||||
const toPin: number[] = [store.activeChapter.id];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const entry = store.activeChapterList[idx + i];
|
||||
if (!entry) break;
|
||||
toPin.push(entry.id);
|
||||
fetchPages(entry.id).then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); }).catch(() => {});
|
||||
}
|
||||
if (idx > 0) { const prev = store.activeChapterList[idx - 1]; toPin.push(prev.id); fetchPages(prev.id).catch(() => {}); }
|
||||
cacheEvict(new Set(toPin));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
||||
appended = new Set([store.activeChapter.id]);
|
||||
appending = false;
|
||||
if (autoNext) {
|
||||
stripChapters = [{ chapterId: store.activeChapter.id, chapterName: store.activeChapter.name, urls: store.pageUrls, startGlobalIdx: 0 }];
|
||||
visibleChapterId = store.activeChapter.id;
|
||||
} else {
|
||||
stripChapters = [];
|
||||
visibleChapterId = null;
|
||||
}
|
||||
if (containerEl) containerEl.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => { if (store.activeChapter?.id && containerEl) containerEl.scrollTop = 0; });
|
||||
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) });
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
|
||||
const mW = store.settings.maxPageWidth ?? 900;
|
||||
const r = store.settings.readingDirection === "rtl";
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (zoomOpen) { zoomOpen = false; return; }
|
||||
if (dlOpen) { dlOpen = false; return; }
|
||||
closeReader(); return;
|
||||
}
|
||||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, mW + 100) }); return; }
|
||||
if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, mW - 100) }); return; }
|
||||
if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; }
|
||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); store.pageNumber = 1; }
|
||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); store.pageNumber = lastPage; }
|
||||
else if (matchesKeybind(e, kb.chapterRight)) {
|
||||
e.preventDefault();
|
||||
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
|
||||
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
||||
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
|
||||
}
|
||||
else if (matchesKeybind(e, kb.chapterLeft)) {
|
||||
e.preventDefault();
|
||||
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
|
||||
const prev = idx > 0 ? list[idx - 1] : null;
|
||||
if (prev) openReader(prev, list);
|
||||
}
|
||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
||||
}
|
||||
|
||||
function handleTap(e: MouseEvent) {
|
||||
if (style === "longstrip") return;
|
||||
const x = e.clientX / window.innerWidth;
|
||||
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
|
||||
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
|
||||
}
|
||||
|
||||
async function runDl(fn: () => Promise<unknown>) {
|
||||
dlBusy = true;
|
||||
try { await fn(); } catch (e: any) { console.error(e); }
|
||||
dlBusy = false; dlOpen = false;
|
||||
}
|
||||
|
||||
let scrollCleanup: (() => void) | undefined;
|
||||
|
||||
onMount(() => {
|
||||
showUi();
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("wheel", onWheel, { passive: false });
|
||||
containerEl?.focus({ preventScroll: true });
|
||||
scrollCleanup = setupScrollTracking();
|
||||
return () => {
|
||||
abortCtrl?.abort();
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("wheel", onWheel);
|
||||
scrollCleanup?.();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!containerEl) return;
|
||||
const _style = style;
|
||||
const _len = store.pageUrls.length;
|
||||
const _auto = autoNext;
|
||||
untrack(() => {
|
||||
scrollCleanup?.();
|
||||
scrollCleanup = setupScrollTracking();
|
||||
});
|
||||
return () => { scrollCleanup?.(); };
|
||||
});
|
||||
|
||||
const stripToRender = $derived(style === "longstrip"
|
||||
? (autoNext && stripChapters.length > 0
|
||||
? stripChapters
|
||||
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls, startGlobalIdx: 0 }])
|
||||
: []);
|
||||
|
||||
const currentGroup = $derived(style === "double" && pageGroups.length
|
||||
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
|
||||
: [store.pageNumber]);
|
||||
</script>
|
||||
|
||||
<div class="root" role="presentation" onmousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
|
||||
|
||||
<div class="topbar" class:hidden={!uiVisible}>
|
||||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||
<button class="icon-btn" onclick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, store.activeChapterList); } }} disabled={!adjacent.prev}>
|
||||
<CaretLeft size={14} weight="light" />
|
||||
</button>
|
||||
<span class="ch-label">
|
||||
<span class="ch-title">{store.activeManga?.title}</span>
|
||||
<span class="ch-sep">/</span>
|
||||
<span>{displayChapter?.name}</span>
|
||||
</span>
|
||||
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||||
<button class="icon-btn" onclick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } }} disabled={!adjacent.next}>
|
||||
<CaretRight size={14} weight="light" />
|
||||
</button>
|
||||
<div class="top-sep"></div>
|
||||
<button class="mode-btn" onclick={cycleFit}>
|
||||
{#if fit === "width"}<ArrowsLeftRight size={14} weight="light" />
|
||||
{:else if fit === "height"}<ArrowsVertical size={14} weight="light" />
|
||||
{:else if fit === "screen"}<ArrowsIn size={14} weight="light" />
|
||||
{:else}<ArrowsOut size={14} weight="light" />{/if}
|
||||
<span class="mode-label">{fitLabel}</span>
|
||||
</button>
|
||||
<div class="zoom-wrap">
|
||||
<button class="zoom-btn" onclick={() => zoomOpen = !zoomOpen}>{Math.round((maxW / 900) * 100)}%</button>
|
||||
{#if zoomOpen}
|
||||
<div class="zoom-popover">
|
||||
<input type="range" class="zoom-slider" min={200} max={2400} step={50} value={maxW}
|
||||
oninput={(e) => updateSettings({ maxPageWidth: Number(e.currentTarget.value) })} />
|
||||
<button class="zoom-reset" onclick={() => updateSettings({ maxPageWidth: 900 })}>{Math.round((maxW / 900) * 100)}%</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
|
||||
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
|
||||
</button>
|
||||
<button class="mode-btn" onclick={cycleStyle}>
|
||||
{#if style === "single"}<Square size={14} weight="light" />{:else}<Rows size={14} weight="light" />{/if}
|
||||
<span class="mode-label">{styleLabel}</span>
|
||||
</button>
|
||||
{#if style !== "single"}
|
||||
<button class="mode-btn" class:active={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
|
||||
<span class="mode-label">Gap</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if style === "longstrip"}
|
||||
<button class="mode-btn" class:active={autoNext} onclick={() => updateSettings({ autoNextChapter: !autoNext })}>
|
||||
<span class="mode-label">Auto</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !autoNext}
|
||||
<button class="mode-btn" class:active={markOnNext} onclick={() => updateSettings({ markReadOnNext: !markOnNext })}>
|
||||
<span class="mode-label">Mk.Read</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="mode-btn" onclick={() => dlOpen = true}>
|
||||
<Download size={14} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
bind:this={containerEl}
|
||||
class="viewer"
|
||||
class:strip={style === "longstrip"}
|
||||
style="--max-page-width:{maxW}px"
|
||||
role="presentation"
|
||||
tabindex="-1"
|
||||
onclick={handleTap}
|
||||
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
||||
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
||||
{/if}
|
||||
|
||||
{#if style === "longstrip"}
|
||||
{#each stripToRender as chunk}
|
||||
{#each chunk.urls as url, i}
|
||||
<img src={url} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 3 ? "eager" : "lazy"} decoding="async" height="1000" />
|
||||
{/each}
|
||||
{/each}
|
||||
<div bind:this={sentinelEl} style="height:1px;flex-shrink:0;overflow-anchor:none"></div>
|
||||
{:else if pageReady}
|
||||
{#if style === "double" && pageGroups.length}
|
||||
<div class="double-wrap">
|
||||
{#each currentGroup as pg}
|
||||
<img src={store.pageUrls[pg - 1]} alt="Page {pg}" class="{imgCls} page-half {pg === currentGroup[0] ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<img src={store.pageUrls[store.pageNumber - 1]} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="transition:opacity 0.1s ease" />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="bottombar" class:hidden={!uiVisible}>
|
||||
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
</button>
|
||||
<button class="nav-btn" onclick={goNext} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
|
||||
<ArrowRight size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if dlOpen && store.activeChapter}
|
||||
{@const queueable = adjacent.remaining.filter(c => !c.isDownloaded)}
|
||||
<div class="dl-backdrop" role="presentation" onclick={() => dlOpen = false}>
|
||||
<div class="dl-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<p class="dl-title">Download</p>
|
||||
<button class="dl-option" disabled={dlBusy || !!store.activeChapter.isDownloaded}
|
||||
onclick={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: store.activeChapter!.id }))}>
|
||||
This chapter
|
||||
<span class="dl-sub">{store.activeChapter.isDownloaded ? "Already downloaded" : store.activeChapter.name}</span>
|
||||
</button>
|
||||
<div class="dl-row">
|
||||
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
|
||||
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.slice(0, nextN).map(c => c.id) }))}>
|
||||
Next chapters
|
||||
<span class="dl-sub">{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
||||
</button>
|
||||
<div class="dl-stepper" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="dl-step-btn" onclick={() => nextN = Math.max(1, nextN - 1)} disabled={nextN <= 1}>−</button>
|
||||
<span class="dl-step-val">{nextN}</span>
|
||||
<button class="dl-step-btn" onclick={() => nextN = Math.min(queueable.length || 1, nextN + 1)} disabled={nextN >= queueable.length}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
|
||||
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map(c => c.id) }))}>
|
||||
All remaining
|
||||
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
|
||||
.topbar { display: flex; align-items: center; gap: var(--sp-1); padding: 0 var(--sp-3); height: 40px; background: var(--bg-void); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; position: relative; z-index: 2; transition: opacity 0.25s ease; }
|
||||
.topbar.hidden, .bottombar.hidden { opacity: 0; pointer-events: none; }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
||||
.ch-label { flex: 1; display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.ch-sep { color: var(--text-faint); }
|
||||
.page-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.top-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
|
||||
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); transition: color var(--t-base), background var(--t-base); }
|
||||
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.mode-label { text-transform: capitalize; }
|
||||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
||||
.zoom-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px var(--sp-2); border-radius: var(--radius-sm); min-width: 36px; text-align: center; transition: color var(--t-base), background var(--t-base); }
|
||||
.zoom-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 160px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
||||
.zoom-slider { width: 140px; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
||||
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
||||
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 2px var(--sp-2); border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); }
|
||||
.zoom-reset:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; overflow-anchor: auto; }
|
||||
.viewer:focus { outline: none; }
|
||||
.img { display: block; user-select: none; image-rendering: auto; }
|
||||
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
|
||||
.fit-width { max-width: var(--max-page-width); width: 100%; height: auto; }
|
||||
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; }
|
||||
.fit-screen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
||||
.fit-original { max-width: none; width: auto; height: auto; }
|
||||
.strip-gap { margin-bottom: 8px; }
|
||||
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--max-page-width) * 2); width: 100%; }
|
||||
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
||||
.gap-left { margin-right: 2px; }
|
||||
.gap-right { margin-left: 2px; }
|
||||
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||
.bottombar { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); padding: var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
|
||||
.nav-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); transition: background var(--t-base), color var(--t-base); }
|
||||
.nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
||||
.nav-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; align-items: flex-start; justify-content: flex-end; padding: 48px var(--sp-4) 0; }
|
||||
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; transform-origin: top right; }
|
||||
.dl-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); }
|
||||
.dl-option { 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-option:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.dl-option:disabled { opacity: 0.3; cursor: default; }
|
||||
.dl-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
||||
.dl-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.dl-stepper { display: flex; align-items: center; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; }
|
||||
.dl-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 28px; font-size: var(--text-base); color: var(--text-muted); background: none; border: none; cursor: pointer; line-height: 1; transition: color var(--t-fast), background var(--t-fast); }
|
||||
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||