Compare commits

...

53 Commits

Author SHA1 Message Date
Youwes09 06cb70048b Feat: Revamped Logo, QOL Home-Screen Additions, Scaling Logic Revamp 2026-03-22 01:26:40 -05:00
Youwes09 d3e62a7a08 Fix: Switch Tauri Conf to Windows Specific 2026-03-21 23:26:33 -05:00
Youwes09 b6ef2b1b3c Fix: Windows Prod-Server Launch 2026-03-21 21:13:58 -07:00
Youwes09 c13a4eb77a Fix: Improve CI Patch 2026-03-20 22:34:32 -05:00
Youwes09 bd972eccf3 Fix: Clear externalBin, Remove Linux CI 2026-03-20 22:29:24 -05:00
Youwes09 9610c0294d Fix: Clear externalBin in Base Config 2026-03-20 22:21:17 -05:00
Youwes09 406819ccca Feat: Bundle Suwayomi JRE for Windows/Linux 2026-03-20 22:13:43 -05:00
Youwes09 272e026210 Fix: Inject Bundle Resources in CI 2026-03-20 21:14:35 -05:00
Youwes09 57bf9d5fb1 Fix: Attempt #1 Windows Workflow 2026-03-20 21:07:19 -05:00
Youwes09 7df7191799 Fix: Attempt to Fix Nix/Flatpak (Testing) 2026-03-20 21:03:53 -05:00
Youwes09 e6b542cd6b Fix: Removed Update Badge from Extensions 2026-03-20 16:01:03 -05:00
Youwes09 4903b066b1 Chore: Finalized Svelte-5 Rewrite (Testing Phase) 2026-03-20 15:58:35 -05:00
Youwes09 96bac1ad2b Chore: Revamped Shared Files for Svelte 5 Rewrite 2026-03-19 23:43:43 -05:00
Youwes09 94b92d000f Chore: Revamped Lib Files for Svelte 5 Rewrite 2026-03-19 23:36:26 -05:00
Youwes09 43630ef72d Chore: Temporary Home-Page (Until Fixed with Rewrite) 2026-03-19 22:53:51 -05:00
Youwes09 161b1f9f52 Chore: Revamped Home-Page (Pending Statistics Display) 2026-03-19 22:17:23 -05:00
Youwes09 816b384d64 Feat: Implemented Series-Link 2026-03-19 22:00:24 -05:00
Youwes09 b772b94c6c Chore: Attempted De-Dupe Patch #1 & Alternative Thumbnails 2026-03-19 21:39:51 -05:00
Youwes09 deb8a5ee02 Chore: Patched Library Completed & Added Home-Page 2026-03-19 21:20:33 -05:00
Youwes09 821e13fc44 chore: migrated context + series-detail + migrate 2026-03-19 00:36:42 -05:00
Youwes09 937054d674 chore: ported over extensions & settings 2026-03-18 23:46:11 -05:00
Youwes09 4532b37201 chore: patched/rewrote reader 2026-03-18 23:16:18 -05:00
Youwes09 73b73e85d7 chore: ported over basics 2026-03-18 23:05:32 -05:00
Youwes09 697116b630 fix: tauri dev added 2026-03-18 22:31:45 -05:00
Youwes09 0e87c51801 chore: init svelte rewrite scaffold 2026-03-18 22:29:03 -05:00
Youwes09 bf38e00cf3 [V1] Fixed Mark as Read Refresh + Auto Feature 2026-03-04 00:00:12 -06:00
Youwes09 eb7360ee05 [V1] Rebased Reader to 9a0afed + Improvements 2026-02-28 18:30:00 -06:00
Youwes09 c9eba3da86 [V1] Fixed Bad State Issue on Reader (WIP) 2026-02-27 22:18:38 -06:00
Youwes09 fc68d3ac7e New Patch for Reader 2026-02-27 17:49:07 -06:00
Youwes09 1fa1c3a2e0 [V1] Search Overhaul + Tag Fixes 2026-02-26 23:55:39 -06:00
Youwes09 8c38330143 [V1] Reader Simplification & Fixes 2026-02-26 23:31:01 -06:00
Youwes09 272d7673ce [V1] Fix NixOS Build 2026-02-26 21:05:24 -06:00
Youwes09 3d074a1fb1 [V1] Attempt on Reader Optimization + Infinite Scroll Glitches 2026-02-26 19:49:48 -06:00
Youwes09 be15cb6ad8 [V1] Patched Tauri Capabilities Permissions 2026-02-25 21:56:05 -06:00
Youwes09 3aee69939b [V1] Forgot to add Binaries prefix 2026-02-25 21:52:57 -06:00
Youwes09 0557f3f2d6 [V1] Fixed Tauri Sidecar Capabilities 2026-02-25 21:51:31 -06:00
Youwes09 817af0d10a [V1] Changed Windows Auto-Detect Binary 2026-02-25 21:40:52 -06:00
Youwes09 70afb08f83 [V1] Updated Search on Workflow 2026-02-25 21:06:43 -06:00
Youwes09 f751f34c68 [V1] Updated Hashing 2026-02-25 21:01:33 -06:00
Youwes09 8c9d3fc783 [V1] Updated SHA Checker 2026-02-25 20:59:19 -06:00
Youwes09 0f0cd87e6d [V1] Windows Workflow 2026-02-25 20:53:20 -06:00
Youwes09 f5a1b13e43 [V1] Attempt to fix Apple Cert Signing 2026-02-25 20:30:06 -06:00
Youwes09 4fca379715 [V1] Fixed Tauri Cert Signing 2026-02-25 20:21:05 -06:00
Youwes09 ac5e3ae53b [V1] Requires Bundle Patch (MacOS) 2026-02-25 20:11:27 -06:00
Youwes09 6d39d5574a [V1] Removed Tauri-MacOS Patch 2026-02-25 20:06:08 -06:00
Youwes09 5e8f0d2f52 [V1] Fix Suwayomi Detection in Workflow 2026-02-25 20:02:11 -06:00
Youwes09 87e2009d4e [V1] MacOS-Patch & Fixes (WIP) 2026-02-25 19:58:37 -06:00
Youwes09 2f5103c48c [V1] Patched Tauri-Targets & Removed Bun Detection 2026-02-25 19:53:12 -06:00
Shozikan 9d9c1b61e7 [V1] Changed to MacOS-Latest & Tauri-Refactor 2026-02-25 19:49:14 -06:00
Shozikan a1a0f360d7 [V1] Fixed ENV & Download Link 2026-02-25 19:43:47 -06:00
Youwes09 9a0afed2b0 [V1] Query-Optimizations & Preparation for MacOS & Windows Compatibility 2026-02-25 19:41:14 -06:00
Youwes09 28e9e3bcf8 [V1] Redid Series Detail Download Layout 2026-02-24 22:02:53 -06:00
Youwes09 ac04c39ead [V1] Prepared for v0.3.0 Release 2026-02-24 20:18:45 -06:00
126 changed files with 13245 additions and 17032 deletions
-66
View File
@@ -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
+248
View File
@@ -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
+147
View File
@@ -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
+1 -1
View File
@@ -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/
+2 -13
View File
@@ -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 \
+3 -75
View File
@@ -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 |
---
+45 -30
View File
@@ -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)
-184
View File
@@ -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"
+1 -1
View File
@@ -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
Generated
+15 -87
View File
@@ -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",
+176 -54
View File
@@ -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)"
'';
};
+2 -2
View File
@@ -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>
+9 -24
View File
@@ -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"
}
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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>
Binary file not shown.
+563 -1897
View File
File diff suppressed because it is too large Load Diff
+136 -33
View File
@@ -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",
+3 -3
View File
@@ -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]
+21 -10
View File
@@ -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"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 B

After

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+336 -39
View File
@@ -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| {
+16 -5
View File
@@ -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": {
+3
View File
@@ -9,5 +9,8 @@
"devtools": true
}
]
},
"bundle": {
"externalBin": []
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"bundle": {
"resources": [
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
"binaries/suwayomi-bundle/jre/**/*"
]
}
}
-12
View File
@@ -1,12 +0,0 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.content {
flex: 1;
overflow: hidden;
min-height: 0;
}
+175
View File
@@ -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>
-193
View File
@@ -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>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

+21
View File
@@ -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

+21
View File
@@ -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

+21 -26
View File
@@ -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

Binary file not shown.

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);
}
-133
View File
@@ -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);
}
-230
View File
@@ -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>
);
}
-441
View File
@@ -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);
}
-535
View File
@@ -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;
}
-306
View File
@@ -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; }
-569
View File
@@ -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; }
-407
View File
@@ -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>
);
}
-15
View File
@@ -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;
}
+45
View File
@@ -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>
-36
View File
@@ -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>
);
}
+317
View File
@@ -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>
-107
View File
@@ -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; }
+68
View File
@@ -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>
-62
View File
@@ -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>
);
}
+353
View File
@@ -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>
-518
View File
@@ -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>
);
}
-55
View File
@@ -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;
}
+67
View File
@@ -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>
-46
View File
@@ -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>
);
}
-85
View File
@@ -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); }
+91
View File
@@ -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>
-69
View File
@@ -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
);
}
+462
View File
@@ -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>
+177
View File
@@ -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>
+372
View File
@@ -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>
+348
View File
@@ -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>
+239
View File
@@ -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>
-152
View File
@@ -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); }
+250
View File
@@ -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>
-244
View File
@@ -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>
);
}
+604
View File
@@ -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>
-264
View File
@@ -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); }
+327
View File
@@ -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>
-431
View File
@@ -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);
}
+473
View File
@@ -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>
-372
View File
@@ -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>
);
}
-245
View File
@@ -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; }
File diff suppressed because it is too large Load Diff
-342
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
-799
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+754
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+705
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More