Compare commits

..

65 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
Youwes09 7d3d76fa6d [V1] Fixed SplashScreen Rasterization/Pixel-Detection 2026-02-24 19:52:17 -06:00
Youwes09 fec0e5d3f6 [V1] Patched MangaPreview & Added Themes (Contrast) 2026-02-24 18:44:19 -06:00
Youwes09 f866d4d0e9 [V1] Major Bug Fixes & Loading Screen (WIP) 2026-02-24 16:14:46 -06:00
Shozikan ac1c0520c5 Change license from MIT to Apache 2.0 2026-02-24 15:28:25 -06:00
Shozikan fff6bde8ad Merge pull request #3 from kx4x/patch-2
Bump package version to 0.2.0
2026-02-24 10:45:31 -08:00
kx4x c07fc90fc8 Bump package version to 0.2.0 2026-02-24 12:54:10 -03:00
Youwes09 523fb40538 [V1] Major Revisions to Search & New Preview + Genre Filter (WIP Commit) 2026-02-23 22:40:00 -06:00
Shozikan fb82abaf21 Merge pull request #1 from kx4x/patch-1
Update repository URL and source in PKGBUILD
2026-02-23 18:04:42 -08:00
kx4x 0a4108218d Update repository URL and source in PKGBUILD 2026-02-23 18:59:21 -03:00
Youwes09 7b61f85833 [V1] Created Toaster & Augmented Explore Tab 2026-02-23 11:36:52 -06:00
Youwes09 cd2d79f80c [V1] Addressed Laggy Single-Page (Applied Cache-Loading) 2026-02-23 10:57:52 -06:00
Youwes09 edf2af8618 [V1] Fixed Downloader Pause/Clear & Began Revision #1 Bug Fixes 2026-02-23 00:03:37 -06:00
120 changed files with 13650 additions and 12317 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/
+7 -20
View File
@@ -1,10 +1,10 @@
pkgname=moku
pkgver=0.1.0
pkgver=0.4.0
pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64')
url="https://github.com/shozikan/Moku"
license=('MIT')
url="https://github.com/Youwes09/Moku"
license=('Apache 2.0')
depends=(
'webkit2gtk-4.1'
'gtk3'
@@ -18,15 +18,13 @@ makedepends=(
'pnpm'
)
source=(
"$pkgname-$pkgver.tar.gz::https://github.com/shozikan/Moku/archive/refs/tags/v$pkgver.tar.gz"
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
"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=(
'0019dfc4b32d63c1392aa264aed2253c1e0c2fb09216f8e2cc269bbfb8bb49b5'
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d'
)
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
prepare() {
cd "Moku-$pkgver"
@@ -35,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
@@ -51,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"
@@ -111,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 |
---
+93 -11
View File
@@ -1,22 +1,104 @@
Todo:
1. Check all Keybind Toggles
2. Update ReadME with Comprehensive Feature List
3. Explore Manga Upscaler
4. Add Zoom-Slider for Zoom in Manga Reader
3. Explore Manga Upscaler & Other Image Processing
4. Font Weird on Flatpak, Investigate and Fix
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
Bugs:
2. Fix Auto-scroll between chapters & state when moving chapters (Implemented but not Fluidic)
3. Patch Chapters to Grid View
5. Fix Keybind Toggles
- Add Back after Search & Clear on Search
- 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
- 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:
1. Frecency based Manga Suggestions
2. Proper Explore Tab
- Add PDF Textbook Support
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
- Migration Features
- Multi-Page Long Screenshot
- Add Consumet Api (Anime & Light Novel Support)
Big Revisions:
0. Expand into fully-fledged reader, with modular manga support
1. Anime & Novel Support
2. Tracker Support
3. Cloudflare Bypass Enable Support
4. macOS Support (feasible)
Test:
1. URL & Extension Additions
Testing:
- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
- Fix the Mark as Read (Glitched)
Completed:
8. Fix Polling on Download Manager (Instantanous Response)
19. Debounce Time on Reader to improve lag (Toggle Setting)
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
17. Change Library Text change to "No manga saved to library, browse sources to add some."
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
7. Fix Scaling (100 = 125% and so forth)
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
11. Reader & UI needs download and other Notifications
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
- Add Refresh Details on Series Details.
- Patch GenreDrill & Integrate into Explore Folder
18. Disable NSFW Extensions option in settings
- Filtering by Genre (Accessed by Clicking tags on Manga)
- Remove Series Detail Mark Read & Unread
20. Expand History (Total Time Read, etc)
12. Delete all Downloads should also cancel all download queues
13. Cancel Download along with Queue & Download Timeout Feature
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
- Extensions Page no Longer Loading efficiently
- Map out MangaPreview tags to GenreDrill
- GenreDrill & GenreFilter pages do not populate completely.
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
- Clean up Migrate Model to be more initutive
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
5. Lock reader on valid chapters to avoid bugs, etc.
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
- Properly Kill Tachidesk-Server
- Fix scaling on splash screen
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
Important Commands:
cd ~/Projects/Manga/Moku
pnpm build
tar -czf packaging/frontend-dist.tar.gz -C dist .
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
3. flatpak build-bundle repo moku.flatpak dev.moku.app
-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",
+192 -70
View File
@@ -2,43 +2,34 @@
description = "Moku manga reader frontend for Suwayomi";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
crane.url = "github:ipetkov/crane";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
nix-appimage = {
url = "github:ralismark/nix-appimage";
url = "github:oxalica/rust-overlay";
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,60 +56,47 @@
|| 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";
src = frontendSrc;
pname = "moku-frontend";
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";
src = frontendSrc;
pname = "moku-frontend";
inherit version;
src = frontendSrc;
fetcherVersion = 1;
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
hash = "sha256-FsZTHeBS9qQ9KYgiwDX1vam6uJXK8OjLe5U6Jfu33lc=";
};
buildPhase = "pnpm build";
buildPhase = "pnpm build";
installPhase = "cp -r dist $out";
};
cargoSrc = lib.cleanSourceWith {
src = ./src-tauri;
src = ./src-tauri;
filter = path: type:
(craneLib.filterCargoSources path type)
|| (lib.hasInfix "/icons/" path)
|| (lib.hasInfix "/icons/" path)
|| (lib.hasInfix "/capabilities/" path)
|| (builtins.baseNameOf path == "tauri.conf.json");
};
commonArgs = {
src = cargoSrc;
cargoToml = ./src-tauri/Cargo.toml;
cargoLock = ./src-tauri/Cargo.lock;
src = cargoSrc;
cargoToml = ./src-tauri/Cargo.toml;
cargoLock = ./src-tauri/Cargo.lock;
strictDeps = true;
buildInputs = runtimeLibs;
nativeBuildInputs = with pkgs; [
pkg-config
wrapGAppsHook3
];
buildInputs = runtimeLibs;
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
preBuild = ''
cp -r ${frontend} ../dist
'';
WEBKIT_DISABLE_COMPOSITING_MODE = "1";
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
@@ -127,22 +105,177 @@
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
pkgs.gtk3
]}" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}"
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
--set GDK_BACKEND wayland \
--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 {
@@ -157,27 +290,16 @@
xdg-utils
];
shellHook = ''
export WEBKIT_DISABLE_COMPOSITING_MODE=1
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

+376 -36
View File
@@ -1,8 +1,9 @@
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;
use tauri::{Manager, WindowEvent};
use tauri_plugin_shell::{ShellExt, process::CommandChild};
use walkdir::WalkDir;
@@ -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,51 +61,376 @@ 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())?;
// f_frsize is the fundamental block size used for block counts.
// f_bsize (block_size()) is just the preferred I/O size and must not be
// used with blocks()/blocks_free() — that gives wildly wrong numbers.
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(),
})
}
#[tauri::command]
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>();
if let Some(child) = state.0.lock().unwrap().take() {
let _ = child.kill();
}
#[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")
.args(["-f", "tachidesk"])
.status();
}
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")]
{
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 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)) => {
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
Ok(())
}
Err(e) => {
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e));
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
Err(SpawnError::SpawnFailed(e.to_string()))
}
}
}
#[tauri::command]
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
kill_tachidesk(&app);
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![get_storage_info])
.setup(|app| {
let shell = app.shell();
let app_handle = app.handle().clone();
let status = shell.command("tachidesk-server").spawn();
match status {
Ok((_rx, child)) => {
println!("Tachidesk server process spawned successfully.");
let state = app_handle.state::<ServerState>();
let mut guard = state.0.lock().unwrap();
*guard = Some(child);
}
Err(e) => {
eprintln!("Failed to spawn Tachidesk server: {}", e);
}
.invoke_handler(tauri::generate_handler![
get_storage_info,
spawn_server,
kill_server,
get_platform_ui_scale,
])
.setup(|_app| Ok(()))
.on_window_event(|window, event| {
if let WindowEvent::Destroyed = event {
kill_tachidesk(window.app_handle());
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running moku");
+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>
-54
View File
@@ -1,54 +0,0 @@
import { useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
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 TitleBar from "./components/layout/TitleBar";
import s from "./App.module.css";
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);
useEffect(() => {
document.documentElement.style.zoom = `${settings.uiScale}%`;
}, [settings.uiScale]);
useEffect(() => {
const prevent = (e: MouseEvent) => e.preventDefault();
document.addEventListener("contextmenu", prevent);
return () => document.removeEventListener("contextmenu", prevent);
}, []);
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]);
// Global Tauri download-progress listener — no polling, always current
useEffect(() => {
type DlPayload = { chapterId: number; mangaId: number; progress: number }[];
const unsub = listen<DlPayload>("download-progress", (e) => {
setActiveDownloads(e.payload);
});
return () => { unsub.then((fn) => fn()); };
}, [setActiveDownloads]);
return (
<div className={s.root}>
{!activeChapter && <TitleBar />}
<div className={s.content}>
{activeChapter ? <Reader /> : <Layout />}
</div>
{settingsOpen && <Settings />}
</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,200 +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 { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.iconBtn:disabled { opacity: 0.3; cursor: default; }
.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;
}
.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);
}
.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);
}
.rowActive { border-color: var(--accent-dim); }
/* 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 { color: var(--color-error); background: var(--color-error-bg); }
.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);
}
-152
View File
@@ -1,152 +0,0 @@
import { useEffect, useState } 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 setActiveDownloads = useStore((s) => s.setActiveDownloads);
async function poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => {
setStatus(d.downloadStatus);
setActiveDownloads(
d.downloadStatus.queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
}))
);
})
.catch(console.error)
.finally(() => setLoading(false));
}
useEffect(() => {
poll();
const id = setInterval(poll, 1500);
return () => clearInterval(id);
}, []);
async function start() { await gql(START_DOWNLOADER).catch(console.error); poll(); }
async function stop() { await gql(STOP_DOWNLOADER).catch(console.error); poll(); }
async function clear() { await gql(CLEAR_DOWNLOADER).catch(console.error); poll(); }
async function dequeue(chapterId: number) {
await gql(DEQUEUE_DOWNLOAD, { chapterId }).catch(console.error);
poll();
}
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}>
{isRunning ? (
<button className={s.iconBtn} onClick={stop} title="Pause">
<Pause size={14} weight="fill" />
</button>
) : (
<button className={s.iconBtn} onClick={start} disabled={queue.length === 0} title="Resume">
<Play size={14} weight="fill" />
</button>
)}
<button className={s.iconBtn} onClick={clear} disabled={queue.length === 0} title="Clear queue">
<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}>{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;
return (
<div
key={item.chapter.id}
className={[s.row, isActive ? s.rowActive : ""].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)}
title="Remove from queue"
>
<X size={12} weight="light" />
</button>
)}
</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 "../sources/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 (navPage === "library" && 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>
-81
View File
@@ -1,81 +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;
background: none;
border: none;
cursor: pointer;
border-radius: var(--radius-lg);
transition: opacity var(--t-base), transform var(--t-base);
padding: 0;
}
.logo:hover { opacity: 0.8; transform: scale(0.96); }
.logo:active { transform: scale(0.92); }
.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);
transition: color var(--t-base), background var(--t-base);
}
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tabActive { color: var(--accent-fg); background: var(--accent-muted); }
.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);
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); }
+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>
-59
View File
@@ -1,59 +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 openSettings = useStore((state) => state.openSettings);
function navigate(id: NavPage) {
setNavPage(id);
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>
-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>
);
}
+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>
+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>
-114
View File
@@ -1,114 +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); }
.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>
-197
View File
@@ -1,197 +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" });
}
// ── Session grouping ──────────────────────────────────────────────────────────
// Consecutive entries for the same manga within SESSION_GAP_MS are collapsed
// into one session card showing the chapter range read.
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 setNavPage = useStore((s) => s.setNavPage);
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]);
function resumeReading(session: ReadingSession) {
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
setNavPage("library");
}
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>
{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>
-347
View File
@@ -1,347 +0,0 @@
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, 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 } from "../../lib/queries";
import { useStore } from "../../store";
import type { Manga, Chapter } from "../../lib/types";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import s from "./Library.module.css";
// Keep in sync with CSS grid: minmax(130px, 1fr) + var(--sp-4)=16px gap
const CARD_MIN_W = 130;
const CARD_GAP = 16;
const ROW_HEIGHT = 260; // ~195px cover + ~40px title + 16px gap + buffer
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}>
<img
src={thumbUrl(manga.thumbnailUrl)}
alt={manga.title}
className={s.cover}
style={{ objectFit: cropCovers ? "cover" : "contain" }}
loading="lazy"
decoding="async"
/>
{!!manga.downloadCount && (
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
)}
</div>
<p className={s.title}>{manga.title}</p>
</button>
);
});
export default function Library() {
const [allManga, setAllManga] = useState<Manga[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | 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 folders = useStore((state) => state.settings.folders);
useEffect(() => {
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
.then((lib) => setAllManga(lib.mangas.nodes))
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
// Reset scroll when filter/search changes
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 ──────────────────────────────────────────────────────
// We need to know columns to chunk filtered into rows.
// Use a ResizeObserver on the scroll container to get real width.
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);
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
}
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 });
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[] {
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)))
.catch(console.error),
},
{
label: "Delete all downloads",
icon: <Trash size={13} weight="light" />,
danger: true,
disabled: !(m.downloadCount && m.downloadCount > 0),
onClick: () => deleteAllDownloads(m),
},
];
}
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}>{error}</p>
</div>
);
return (
<div className={s.root} ref={scrollRef}>
<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={() => setLibraryTagFilter(
active ? libraryTagFilter.filter((t) => t !== tag) : [...libraryTagFilter, 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>
) : (
/* Virtual scroll container */
<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}
/>
))}
{/* Ghost cards on last row to fill grid */}
{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)}
/>
)}
</div>
);
}
@@ -1,478 +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);
}
+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>
-298
View File
@@ -1,298 +0,0 @@
import { useState, useEffect } from "react";
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning } 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;
}
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[]>([]);
const [searching, setSearching] = useState(false);
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
const [loadingMatch, setLoadingMatch] = useState(false);
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));
}, []);
async function searchSource() {
if (!selectedSource || !query.trim()) return;
setSearching(true);
setResults([]);
setError(null);
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: selectedSource.id, type: "SEARCH", page: 1, query: query.trim(),
});
setResults(d.fetchSourceManga.mangas);
} catch (e: any) {
setError(e.message);
} finally {
setSearching(false);
}
}
async function selectMatch(m: Manga) {
setLoadingMatch(true);
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 });
setStep("confirm");
} catch (e: any) {
setError(e.message);
} finally {
setLoadingMatch(false);
}
}
async function migrate() {
if (!selectedMatch) return;
setMigrating(true);
setError(null);
try {
const { manga: newManga, chapters: newChapters } = selectedMatch;
// Build read/bookmark/progress maps from old chapters keyed by chapterNumber
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! });
}
}
// Migrate read state
if (toMarkRead.length) {
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
}
// Migrate bookmarks
if (toMarkBookmarked.length) {
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
}
// Migrate in-progress pages one by one (different lastPageRead per chapter)
for (const { id, lastPageRead } of progressUpdates) {
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
}
// Add new to library, remove old
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;
return (
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className={s.modal}>
<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}>
{(["source", "search", "confirm"] as Step[]).map((st, i) => (
<div key={st} className={[s.step, step === st ? s.stepActive : "", i < ["source","search","confirm"].indexOf(step) ? s.stepDone : ""].join(" ").trim()}>
<span className={s.stepDot}>{i < ["source","search","confirm"].indexOf(step) ? <Check size={9} weight="bold" /> : i + 1}</span>
<span className={s.stepLabel}>{st.charAt(0).toUpperCase() + st.slice(1)}</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={() => { setSelectedSource(src); setStep("search"); searchSource(); }}
>
<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}>
<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" && searchSource()}
autoFocus
/>
</div>
<button className={s.searchBtn} onClick={searchSource} disabled={searching}>
{searching ? <CircleNotch size={13} weight="light" className="anim-spin" /> : "Search"}
</button>
<button className={s.backBtn} onClick={() => { setStep("source"); setResults([]); }}>
Back
</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>
</div>
))}
{!searching && results.map((m) => (
<button
key={m.id}
className={s.resultRow}
onClick={() => selectMatch(m)}
disabled={loadingMatch}
>
<div className={s.resultCoverWrap}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
</div>
<span className={s.resultTitle}>{m.title}</span>
{loadingMatch && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
</button>
))}
{!searching && results.length === 0 && query && (
<div className={s.centered}><span className={s.hint}>No results.</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>
</div>
<ArrowRight size={20} weight="light" className={s.confirmArrow} />
<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>
</div>
</div>
<div className={s.confirmStats}>
<div className={s.statRow}>
<span className={s.statLabel}>Chapters on new source</span>
<span className={s.statVal}>{selectedMatch.chapters.length}</span>
</div>
<div className={s.statRow}>
<span className={s.statLabel}>Read progress to migrate</span>
<span className={s.statVal}>{readCount} / {totalCount} chapters</span>
</div>
<div className={s.statRow}>
<span className={s.statLabel}>Matched chapters</span>
<span className={s.statVal}>{selectedMatch.readCount} will carry over</span>
</div>
</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</>
: "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; }
-967
View File
@@ -1,967 +0,0 @@
import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from "react";
import {
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
Square, Rows, Download, ArrowsLeftRight,
ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import {
FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ,
ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD,
} from "../../lib/queries";
import { useStore, type FitMode } from "../../store";
import { matchesKeybind, toggleFullscreen } from "../../lib/keybinds";
import s from "./Reader.module.css";
// ── LRU image cache ───────────────────────────────────────────────────────────
// Keeps browser memory in check by revoking object-URLs for chapters that
// have scrolled far away. We cache by chapterId (not URL) so that we can
// drop a whole chapter at once.
const MAX_CACHED_CHAPTERS = 6;
// Track insertion order so we can evict the oldest chapter.
const chapterCacheOrder: number[] = [];
function touchChapterOrder(chapterId: number) {
const idx = chapterCacheOrder.indexOf(chapterId);
if (idx !== -1) chapterCacheOrder.splice(idx, 1);
chapterCacheOrder.push(chapterId);
}
function evictOldestChapter(
pageCache: React.MutableRefObject<Map<number, string[]>>,
keepIds: Set<number>,
): number | null {
for (let i = 0; i < chapterCacheOrder.length; i++) {
const id = chapterCacheOrder[i];
if (!keepIds.has(id)) {
chapterCacheOrder.splice(i, 1);
pageCache.current.delete(id);
return id;
}
}
return null;
}
/** Fire-and-forget: create an Image and let the browser cache it. */
function preloadImage(url: string) {
const img = new Image();
img.src = url;
}
/**
* Decode a single image fully before resolving.
* Used to avoid showing a half-painted page.
*/
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(); // don't block on error
img.src = url;
});
}
function measureAspect(url: string): Promise<number> {
return new Promise((res) => {
const img = new Image();
img.onload = () => res(img.naturalWidth / img.naturalHeight);
img.onerror = () => res(0.67);
img.src = url;
});
}
// ── Download modal ────────────────────────────────────────────────────────────
function DownloadModal({
chapter,
remaining,
onClose,
}: {
chapter: { id: number; name: string };
remaining: { id: number }[];
onClose: () => void;
}) {
const [nextN, setNextN] = useState(5);
const [busy, setBusy] = useState(false);
const run = async (fn: () => Promise<unknown>) => {
setBusy(true);
await fn().catch(console.error);
setBusy(false);
onClose();
};
return (
<div className={s.dlBackdrop} onClick={onClose}>
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
<p className={s.dlTitle}>Download</p>
<button className={s.dlOption} disabled={busy}
onClick={() => run(() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }))}>
This chapter
<span className={s.dlSub}>{chapter.name}</span>
</button>
<div className={s.dlRow}>
<button className={s.dlOption} disabled={busy || !remaining.length}
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
chapterIds: remaining.slice(0, nextN).map((c) => c.id),
}))}>
Next chapters
<span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span>
</button>
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
<button className={s.dlStepBtn}
onClick={() => setNextN((n) => Math.max(1, n - 1))}
disabled={nextN <= 1}></button>
<span className={s.dlStepVal}>{nextN}</span>
<button className={s.dlStepBtn}
onClick={() => setNextN((n) => Math.min(remaining.length || 1, n + 1))}
disabled={nextN >= remaining.length}>+</button>
</div>
</div>
<button className={s.dlOption} disabled={busy || !remaining.length}
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
chapterIds: remaining.map((c) => c.id),
}))}>
All remaining
<span className={s.dlSub}>{remaining.length} chapters</span>
</button>
</div>
</div>
);
}
// ── Zoom slider popover ───────────────────────────────────────────────────────
function ZoomPopover({
value,
onChange,
onReset,
onClose,
}: {
value: number;
onChange: (v: number) => void;
onReset: () => void;
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [onClose]);
return (
<div className={s.zoomPopover} ref={ref}>
<input
type="range"
className={s.zoomSlider}
min={200}
max={2400}
step={50}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<button className={s.zoomResetBtn} onClick={onReset}>
{Math.round((value / 900) * 100)}%
</button>
</div>
);
}
// ── Reader ────────────────────────────────────────────────────────────────────
/** One chapter's worth of pages in the infinite strip */
interface StripChapter {
chapterId: number;
chapterName: string;
urls: string[];
/** Global page index offset for pages in this strip chunk */
startGlobalIdx: number;
}
export default function Reader() {
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef(0);
const pageNumRef = useRef(1);
const pageCache = useRef<Map<number, string[]>>(new Map());
const aspectCache = useRef<Map<string, number>>(new Map());
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const uiRef = useRef<HTMLDivElement>(null);
// Track which chapters are being fetched so we don't double-fire
const fetchingRef = useRef<Set<number>>(new Set());
// Whether we've already appended the next chapter into the strip
const appendedRef = useRef<Set<number>>(new Set());
// The chapter id whose pages are currently being loaded (prevents stale sets)
const loadingChapterRef = useRef<number | null>(null);
// Mirror of stripChapters in a ref so the scroll handler never closes over stale state
const stripChaptersRef = useRef<StripChapter[]>([]);
// Scroll anchor: captured just before a head-trim so useLayoutEffect can restore position
const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dlOpen, setDlOpen] = useState(false);
const [zoomOpen, setZoomOpen] = useState(false);
const [uiVisible, setUiVisible] = useState(true);
const markedReadRef = useRef<Set<number>>(new Set());
const [pageGroups, setPageGroups] = useState<number[][]>([]);
// True only after the first page of the new chapter has been decoded,
// preventing any flash of the previous chapter's image.
const [pageReady, setPageReady] = useState(false);
/**
* The infinite strip: an ordered list of chapter chunks.
* In non-longstrip modes this is unused only pageUrls matters.
*/
const [stripChapters, setStripChapters] = useState<StripChapter[]>([]);
/**
* In longstrip autoNext mode, this tracks which chapter the user is
* currently reading (for topbar display) without triggering a full reload.
*/
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
// Keep the ref mirror in sync so the scroll handler always sees current strip state
useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]);
// Restore scroll position synchronously after a head-trim, before the browser paints
useLayoutEffect(() => {
const anchor = scrollAnchorRef.current;
if (!anchor || !containerRef.current) return;
scrollAnchorRef.current = null;
const gained = containerRef.current.scrollHeight - anchor.scrollHeight;
// gained is negative when we removed nodes (scrollHeight shrank)
// We want scrollTop to decrease by the same amount so the visible content stays put.
// But since we removed nodes from the top, scrollHeight already shrank —
// we just need to subtract the removed pixel height from scrollTop.
if (gained < 0) {
containerRef.current.scrollTop = Math.max(0, anchor.scrollTop + gained);
}
}, [stripChapters]);
const {
activeManga, activeChapter, activeChapterList,
pageUrls, pageNumber, settings,
setPageUrls, setPageNumber, closeReader, openReader, openSettings,
updateSettings, addHistory,
} = useStore();
const kb = settings.keybinds;
const rtl = settings.readingDirection === "rtl";
const fit = settings.fitMode ?? "width";
const style = settings.pageStyle ?? "single";
const maxW = settings.maxPageWidth ?? 900;
const autoNext = settings.autoNextChapter ?? false;
useEffect(() => { pageNumRef.current = pageNumber; }, [pageNumber]);
// ── UI autohide ──────────────────────────────────────────────────────────────
const scheduleHide = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000);
}, []);
const showUi = useCallback(() => {
setUiVisible(true);
scheduleHide();
}, [scheduleHide]);
useEffect(() => {
scheduleHide();
return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); };
}, []);
// ── Auto-focus viewer so spacebar/arrows work ───────────────────────────────
useEffect(() => {
containerRef.current?.focus({ preventScroll: true });
}, [activeChapter?.id]);
// ── Fetch helpers ────────────────────────────────────────────────────────────
const fetchPages = useCallback(async (chapterId: number): Promise<string[]> => {
const cached = pageCache.current.get(chapterId);
if (cached) {
touchChapterOrder(chapterId);
return cached;
}
if (fetchingRef.current.has(chapterId)) {
// Poll until another in-flight fetch resolves
return new Promise((resolve) => {
const interval = setInterval(() => {
const c = pageCache.current.get(chapterId);
if (c) { clearInterval(interval); resolve(c); }
}, 50);
});
}
fetchingRef.current.add(chapterId);
const d = await gql<{ fetchChapterPages: { pages: string[] } }>(
FETCH_CHAPTER_PAGES, { chapterId }
);
const urls = d.fetchChapterPages.pages.map(thumbUrl);
pageCache.current.set(chapterId, urls);
touchChapterOrder(chapterId);
// Evict oldest chapters if we're over the limit, but always keep the
// immediately adjacent chapters so navigation is instant.
while (pageCache.current.size > MAX_CACHED_CHAPTERS) {
evictOldestChapter(pageCache, new Set([chapterId]));
}
fetchingRef.current.delete(chapterId);
return urls;
}, []);
// ── Load pages ──────────────────────────────────────────────────────────────
useEffect(() => {
if (!activeChapter) return;
setLoading(true); setError(null); setPageGroups([]); setPageReady(false);
// Reset strip state for new chapter navigation (non-scroll transitions)
appendedRef.current = new Set();
markedReadRef.current = new Set();
const targetId = activeChapter.id;
loadingChapterRef.current = targetId;
fetchPages(targetId)
.then(async (urls) => {
// Discard result if the user has already navigated to a different chapter
if (loadingChapterRef.current !== targetId) return;
// Decode the first page before committing so no previous chapter flashes
await decodeImage(urls[0]);
if (loadingChapterRef.current !== targetId) return;
setPageUrls(urls);
setPageReady(true);
if (style === "longstrip" && autoNext) {
setStripChapters([{
chapterId: activeChapter.id,
chapterName: activeChapter.name,
urls,
startGlobalIdx: 0,
}]);
setVisibleChapterId(activeChapter.id);
} else {
setStripChapters([]);
setVisibleChapterId(null);
}
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => {
if (loadingChapterRef.current === targetId) setLoading(false);
});
}, [activeChapter?.id]);
// ── Double-page grouping ─────────────────────────────────────────────────────
// Page 1 (cover) always solo. Wide pages (aspect > 1.2) always solo.
// Remaining portrait pages pair left-to-right: [2,3], [4,5], ...
useEffect(() => {
if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
let cancelled = false;
(async () => {
const aspects: number[] = [];
for (const url of pageUrls) {
if (aspectCache.current.has(url)) {
aspects.push(aspectCache.current.get(url)!);
} else {
const a = await measureAspect(url);
aspectCache.current.set(url, a);
aspects.push(a);
}
}
if (cancelled) return;
const groups: number[][] = [];
groups.push([1]);
let i = 2;
while (i <= pageUrls.length) {
const a = aspects[i - 1];
if (a > 1.2) {
groups.push([i]); i++;
} else if (i === pageUrls.length) {
groups.push([i]); i++;
} else {
const nextA = aspects[i];
if (nextA !== undefined && nextA <= 1.2) {
// Book order: left page is i, right page is i+1
groups.push(rtl ? [i + 1, i] : [i, i + 1]);
i += 2;
} else {
groups.push([i]); i++;
}
}
}
setPageGroups(groups);
})();
return () => { cancelled = true; };
}, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);
// ── Preload ─────────────────────────────────────────────────────────────────
// Eagerly decode pages ahead; fire-and-forget preload for pages behind.
useEffect(() => {
const ahead = settings.preloadPages ?? 3;
for (let i = 1; i <= ahead; i++) {
const url = pageUrls[pageNumber - 1 + i];
if (url) decodeImage(url); // uses browser cache — no duplicate network request
}
// Also keep one page behind warm
const behindUrl = pageUrls[pageNumber - 2];
if (behindUrl) preloadImage(behindUrl);
}, [pageNumber, pageUrls, settings.preloadPages]);
// ── Adjacent chapters ────────────────────────────────────────────────────────
const adjacent = useMemo(() => {
if (!activeChapter || !activeChapterList.length)
return { prev: null, next: null, remaining: [] };
const idx = activeChapterList.findIndex((c) => c.id === activeChapter.id);
return {
prev: idx > 0 ? activeChapterList[idx - 1] : null,
next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
remaining: activeChapterList.slice(idx + 1),
};
}, [activeChapter, activeChapterList]);
useEffect(() => {
const pinned = new Set<number>();
if (activeChapter) pinned.add(activeChapter.id);
if (adjacent.next) pinned.add(adjacent.next.id);
if (adjacent.prev) pinned.add(adjacent.prev.id);
const preload = (id: number) => {
fetchPages(id)
.then((urls) => urls.slice(0, 3).forEach(preloadImage))
.catch(() => {});
};
if (adjacent.next) preload(adjacent.next.id);
if (adjacent.prev) preload(adjacent.prev.id);
// After preloads are kicked off, evict anything beyond MAX_CACHED_CHAPTERS
// that isn't pinned as adjacent or current.
while (pageCache.current.size > MAX_CACHED_CHAPTERS) {
const evicted = evictOldestChapter(pageCache, pinned);
if (evicted === null) break; // nothing left to evict
}
}, [adjacent.next?.id, adjacent.prev?.id]);
const lastPage = pageUrls.length;
/**
* In infinite-strip mode, the topbar shows whichever chapter the user is
* currently scrolled into rather than the "root" chapter we opened with.
*/
const displayChapter = useMemo(() => {
if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter;
return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter;
}, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]);
/**
* In infinite-strip mode, the "last page" shown in the topbar is relative
* to the currently visible chapter chunk.
*/
const visibleChunkLastPage = useMemo(() => {
if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return lastPage;
const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
return chunk ? chunk.urls.length : lastPage;
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]);
/** Page number within the currently visible chapter chunk (for topbar) */
const visibleChunkPage = useMemo(() => {
if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return pageNumber;
const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
if (!chunk) return pageNumber;
return Math.max(1, pageNumber - chunk.startGlobalIdx);
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]);
// ── Auto-mark read + history ─────────────────────────────────────────────────
useEffect(() => {
if (!activeChapter || !lastPage) return;
if (activeManga) {
addHistory({
mangaId: activeManga.id, mangaTitle: activeManga.title,
thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id,
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
});
}
if (settings.autoMarkRead && pageNumber === lastPage) {
if (!markedReadRef.current.has(activeChapter.id)) {
markedReadRef.current.add(activeChapter.id);
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
}
}
}, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead]);
// ── Navigation ──────────────────────────────────────────────────────────────
const advanceGroup = useCallback((forward: boolean) => {
if (!pageGroups.length) return;
const gi = pageGroups.findIndex((g) => g.includes(pageNumber));
if (forward) {
if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]);
else if (adjacent.next) { setPageNumber(1); openReader(adjacent.next, activeChapterList); }
else closeReader();
} else {
if (gi > 0) setPageNumber(pageGroups[gi - 1][0]);
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
}
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
const goForward = useCallback(() => {
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
if (pageNumber < lastPage) {
const nextUrl = pageUrls[pageNumber]; // pageNumber is 1-based, so index is pageNumber
if (nextUrl) {
decodeImage(nextUrl).then(() => setPageNumber(pageNumber + 1));
} else {
setPageNumber(pageNumber + 1);
}
} else if (adjacent.next) {
setPageNumber(1);
openReader(adjacent.next, activeChapterList);
} else {
closeReader();
}
}, [pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
const goBack = useCallback(() => {
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
if (pageNumber > 1) {
const prevUrl = pageUrls[pageNumber - 2]; // 0-based index of previous page
if (prevUrl) {
decodeImage(prevUrl).then(() => setPageNumber(pageNumber - 1));
} else {
setPageNumber(pageNumber - 1);
}
} else if (adjacent.prev) {
openReader(adjacent.prev, activeChapterList);
}
}, [pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
const goNext = rtl ? goBack : goForward;
const goPrev = rtl ? goForward : goBack;
function cycleStyle() {
const cycle = ["single", "longstrip"] as const;
const cur = style === "double" ? "single" : style;
const next = cycle[(cycle.indexOf(cur as any) + 1) % cycle.length];
updateSettings({ pageStyle: next });
}
function cycleFit() {
const cycle: FitMode[] = ["width", "height", "screen", "original"];
updateSettings({ fitMode: cycle[(cycle.indexOf(fit) + 1) % cycle.length] });
}
// ── Ctrl+scroll → zoom ───────────────────────────────────────────────────────
useEffect(() => {
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
e.preventDefault();
const delta = e.deltaY < 0 ? 50 : -50;
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + delta)) });
};
window.addEventListener("wheel", onWheel, { passive: false });
return () => window.removeEventListener("wheel", onWheel);
}, [maxW]);
// ── Keybinds ─────────────────────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement).tagName === "INPUT") return;
// Escape: close overlays in priority order, then exit reader
if (e.key === "Escape") {
e.preventDefault();
if (zoomOpen) { setZoomOpen(false); return; }
if (dlOpen) { setDlOpen(false); return; }
closeReader();
return;
}
// Ctrl += / Ctrl + / Ctrl - / Ctrl 0 → zoom
if (e.ctrlKey && (e.key === "=" || e.key === "+")) {
e.preventDefault();
updateSettings({ maxPageWidth: Math.min(2400, maxW + 100) });
return;
}
if (e.ctrlKey && e.key === "-") {
e.preventDefault();
updateSettings({ maxPageWidth: Math.max(200, maxW - 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(); setPageNumber(1); }
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen, maxW]);
// ── Longstrip scroll tracker ─────────────────────────────────────────────────
// Tracks current page number. In autoNext mode, appends the next chapter's
// pages directly into the strip (no re-render / scroll reset) so the flow
// is one seamless ribbon of images.
useEffect(() => {
const el = containerRef.current;
if (!el || style !== "longstrip") return;
const onScroll = () => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
if (!el) return;
const imgs = Array.from(el.querySelectorAll("img[data-page]")) as HTMLElement[];
// Find the image whose center is closest to the viewport center
const viewMid = el.scrollTop + el.clientHeight * 0.5;
let closest = 0;
let closestDist = Infinity;
for (let i = 0; i < imgs.length; i++) {
const imgMid = imgs[i].offsetTop + imgs[i].offsetHeight * 0.5;
const dist = Math.abs(imgMid - viewMid);
if (dist < closestDist) { closestDist = dist; closest = i; }
}
const n = closest + 1;
if (n !== pageNumRef.current) setPageNumber(n);
// ── Infinite append ──────────────────────────────────────────────────
if (!autoNext) {
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
return;
}
const strip = stripChaptersRef.current;
// Silently update visibleChapterId as we scroll into each chunk
for (const chunk of strip) {
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
if (chunk.chapterId !== visibleChapterId) {
setVisibleChapterId(chunk.chapterId);
if (settings.autoMarkRead) {
const prevChunk = strip[strip.indexOf(chunk) - 1];
if (prevChunk) {
if (!markedReadRef.current.has(prevChunk.chapterId)) {
markedReadRef.current.add(prevChunk.chapterId);
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
}
}
}
}
break;
}
}
// Append next chapter when within 300px of the bottom
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
if (!nearBottom) return;
const lastChunk = strip[strip.length - 1];
if (!lastChunk) return;
const lastChunkIdx = activeChapterList.findIndex((c) => c.id === lastChunk.chapterId);
if (lastChunkIdx < 0 || lastChunkIdx >= activeChapterList.length - 1) return;
const nextChEntry = activeChapterList[lastChunkIdx + 1];
if (!nextChEntry || appendedRef.current.has(nextChEntry.id)) return;
appendedRef.current.add(nextChEntry.id);
fetchPages(nextChEntry.id).then((urls) => {
setStripChapters((prev) => {
const lastInPrev = prev[prev.length - 1];
const newStart = lastInPrev ? lastInPrev.startGlobalIdx + lastInPrev.urls.length : 0;
const next = [
...prev,
{ chapterId: nextChEntry.id, chapterName: nextChEntry.name, urls, startGlobalIdx: newStart },
];
const MAX_STRIP_CHAPTERS = 3;
if (next.length > MAX_STRIP_CHAPTERS) {
const toRemove = next.length - MAX_STRIP_CHAPTERS;
// Snapshot scroll position now, inside the state updater, before React
// removes the nodes. useLayoutEffect will restore it after the DOM mutation.
scrollAnchorRef.current = { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight };
return next.slice(toRemove);
}
return next;
});
}).catch(console.error);
});
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
cancelAnimationFrame(rafRef.current);
};
}, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages, visibleChapterId]);
// Reset scroll position when switching chapters in non-longstrip modes
useEffect(() => {
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
}, [pageNumber, style]);
// When switching to longstrip, reset scroll to top and rebuild strip from current chapter
useEffect(() => {
if (style === "longstrip" && containerRef.current) {
containerRef.current.scrollTop = 0;
if (activeChapter && pageUrls.length > 0) {
appendedRef.current = new Set();
if (autoNext) {
setStripChapters([{
chapterId: activeChapter.id,
chapterName: activeChapter.name,
urls: pageUrls,
startGlobalIdx: 0,
}]);
setVisibleChapterId(activeChapter.id);
} else {
// Plain longstrip — no multi-chapter strip
setStripChapters([]);
setVisibleChapterId(null);
}
}
} else if (style !== "longstrip") {
setStripChapters([]);
setVisibleChapterId(null);
}
}, [activeChapter?.id, style, autoNext]);
function handleTap(e: React.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(); }
}
// ── CSS vars ─────────────────────────────────────────────────────────────────
const cssVars = { "--max-page-width": `${maxW}px` } as React.CSSProperties;
const imgCls = [
s.img,
fit === "width" && s.fitWidth,
fit === "height" && s.fitHeight,
fit === "screen" && s.fitScreen,
fit === "original" && s.fitOriginal,
settings.optimizeContrast && s.optimizeContrast,
].filter(Boolean).join(" ");
// ── Icons ────────────────────────────────────────────────────────────────────
const fitIcon =
fit === "width" ? <ArrowsLeftRight size={14} weight="light" /> :
fit === "height" ? <ArrowsVertical size={14} weight="light" /> :
fit === "screen" ? <ArrowsIn size={14} weight="light" /> :
<ArrowsOut size={14} weight="light" />;
const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit];
const styleIcon = style === "single" ? <Square size={14} weight="light" /> : <Rows size={14} weight="light" />;
if (loading) return (
<div className={s.center}>
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
</div>
);
if (error) return (
<div className={s.center}><p className={s.errorMsg}>{error}</p></div>
);
return (
<div
className={s.root}
onMouseMove={(e) => {
const fromTop = e.clientY;
const fromBottom = window.innerHeight - e.clientY;
if (fromTop < 60 || fromBottom < 60) showUi();
}}
>
{/* ── Topbar ── */}
<div
ref={uiRef}
className={[s.topbar, uiVisible ? "" : s.uiHidden].join(" ")}
>
<button className={s.iconBtn} onClick={closeReader} title="Close reader">
<X size={15} weight="light" />
</button>
<button
className={s.iconBtn}
onClick={() => adjacent.prev && openReader(adjacent.prev, activeChapterList)}
disabled={!adjacent.prev}
title="Previous chapter"
>
<CaretLeft size={14} weight="light" />
</button>
<span className={s.chLabel}>
<span className={s.chTitle}>{activeManga?.title}</span>
<span className={s.chSep}>/</span>
<span>{displayChapter?.name}</span>
</span>
<span className={s.pageLabel}>{visibleChunkPage} / {visibleChunkLastPage || "…"}</span>
<button
className={s.iconBtn}
onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
disabled={!adjacent.next}
title="Next chapter"
>
<CaretRight size={14} weight="light" />
</button>
<div className={s.topSep} />
{/* Fit mode */}
<button className={s.modeBtn} onClick={cycleFit} title={`Fit mode: ${fitLabel}\nCtrl+scroll to zoom`}>
{fitIcon}
<span className={s.modeBtnLabel}>{fitLabel}</span>
</button>
{/* Zoom */}
<div className={s.zoomWrap}>
<button
className={s.zoomBtn}
onClick={() => setZoomOpen((o) => !o)}
title="Zoom (click for slider, Ctrl+scroll)"
>
{Math.round((maxW / 900) * 100)}%
</button>
{zoomOpen && (
<ZoomPopover
value={maxW}
onChange={(v) => updateSettings({ maxPageWidth: v })}
onReset={() => updateSettings({ maxPageWidth: 900 })}
onClose={() => setZoomOpen(false)}
/>
)}
</div>
{/* RTL */}
<button
className={[s.modeBtn, rtl ? s.modeBtnActive : ""].join(" ")}
onClick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}
title={`Direction: ${rtl ? "RTL" : "LTR"}`}
>
<ArrowsLeftRight size={14} weight="light" />
<span className={s.modeBtnLabel}>{rtl ? "RTL" : "LTR"}</span>
</button>
{/* Page style */}
<button className={s.modeBtn} onClick={cycleStyle} title={`Layout: ${style}`}>
{styleIcon}
<span className={s.modeBtnLabel}>{style}</span>
</button>
{/* Page gap toggle */}
{style !== "single" && (
<button
className={[s.modeBtn, settings.pageGap ? s.modeBtnActive : ""].join(" ")}
onClick={() => updateSettings({ pageGap: !settings.pageGap })}
title="Toggle page gap"
>
<span className={s.modeBtnLabel}>Gap</span>
</button>
)}
{/* Auto-next chapter */}
{style === "longstrip" && (
<button
className={[s.modeBtn, autoNext ? s.modeBtnActive : ""].join(" ")}
onClick={() => updateSettings({ autoNextChapter: !autoNext })}
title="Auto-advance to next chapter"
>
<span className={s.modeBtnLabel}>Auto</span>
</button>
)}
{/* Download */}
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
<Download size={14} weight="light" />
</button>
</div>
{/* ── Viewer ── */}
<div
ref={containerRef}
className={[s.viewer, style === "longstrip" ? s.viewerStrip : ""].join(" ")}
style={cssVars}
tabIndex={-1}
onClick={handleTap}
onKeyDown={(e) => {
if (e.key === " " && style === "longstrip") {
e.preventDefault();
containerRef.current?.scrollBy({ top: containerRef.current.clientHeight * 0.85, behavior: "smooth" });
}
}}
>
{style === "longstrip" ? (
<>
{(autoNext && stripChapters.length > 0 ? stripChapters : [{
chapterId: activeChapter?.id ?? 0,
chapterName: activeChapter?.name ?? "",
urls: pageUrls,
startGlobalIdx: 0,
}]).map((chunk) =>
chunk.urls.map((url, i) => {
const globalIdx = chunk.startGlobalIdx + i;
return (
<img
key={`${chunk.chapterId}-${i}`}
src={url}
alt={`${chunk.chapterName} Page ${i + 1}`}
data-page={globalIdx + 1}
data-chapter={chunk.chapterId}
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
loading={globalIdx < 3 ? "eager" : "lazy"}
decoding="async"
/>
);
})
)}
</>
) : (
pageReady && (
<img
key={pageNumber}
src={pageUrls[pageNumber - 1]}
alt={`Page ${pageNumber}`}
className={imgCls}
decoding="async"
/>
)
)}
</div>
{/* ── Bottom nav ── */}
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
<button className={s.navBtn} onClick={goPrev} disabled={pageNumber === 1 && !adjacent.prev}>
<ArrowLeft size={13} weight="light" />
</button>
<button className={s.navBtn} onClick={goNext} disabled={pageNumber === lastPage && !adjacent.next}>
<ArrowRight size={13} weight="light" />
</button>
</div>
{dlOpen && activeChapter && (
<DownloadModal
chapter={activeChapter}
remaining={adjacent.remaining}
onClose={() => setDlOpen(false)}
/>
)}
</div>
);
}
-128
View File
@@ -1,128 +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-4);
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; 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-lg); 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: 8px 0;
}
.searchInput::placeholder { color: var(--text-faint); }
.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; }
.langBar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-1);
padding: var(--sp-2) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.langBtn {
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);
}
.langBtn:hover { color: var(--text-muted); border-color: var(--border-strong); }
.langBtnActive {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.langBtnActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
.sourceCount {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
margin-left: auto;
}
.results { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-6); }
.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); }
.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;
}
.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);
}
.skCard { flex-shrink: 0; width: 110px; }
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; }
.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); }
File diff suppressed because it is too large Load Diff
-206
View File
@@ -1,206 +0,0 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { useStore } from "../../store";
import type { Manga, Source } from "../../lib/types";
import s from "./Search.module.css";
interface SourceResult {
source: Source;
mangas: Manga[];
loading: boolean;
error: string | null;
}
const CONCURRENCY = 3;
async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>
): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
const item = items[i++];
await fn(item).catch(() => {});
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
export default function Search() {
const [query, setQuery] = useState("");
const [submitted, setSubmitted] = useState("");
const [results, setResults] = useState<SourceResult[]>([]);
const [allSources, setAllSources] = useState<Source[]>([]);
const [loadingSources, setLoadingSources] = useState(false);
const [activeLang, setActiveLang] = useState<string>("preferred");
const inputRef = useRef<HTMLInputElement>(null);
const setActiveManga = useStore((st) => st.setActiveManga);
const setNavPage = useStore((st) => st.setNavPage);
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
useEffect(() => {
setLoadingSources(true);
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => setAllSources(d.sources.nodes.filter((s) => s.id !== "0")))
.catch(console.error)
.finally(() => setLoadingSources(false));
}, []);
const langs = ["preferred", ...Array.from(new Set(allSources.map((s) => s.lang))).sort(), "all"];
const visibleSources = allSources.filter((src) => {
if (activeLang === "all") return true;
if (activeLang === "preferred") return src.lang === preferredLang;
return src.lang === activeLang;
});
const runSearch = useCallback(async () => {
const q = query.trim();
if (!q || !visibleSources.length) return;
setSubmitted(q);
setResults(visibleSources.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
await runConcurrent(visibleSources, async (src) => {
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "SEARCH", page: 1, query: q,
});
setResults((prev) => prev.map((r) =>
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
));
} catch (e: any) {
setResults((prev) => prev.map((r) =>
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
));
}
});
}, [query, visibleSources]);
function openManga(m: Manga) {
setActiveManga(m);
setNavPage("library");
}
const hasResults = results.some((r) => r.mangas.length > 0);
const allDone = results.every((r) => !r.loading);
return (
<div className={s.root}>
<div className={s.header}>
<h1 className={s.heading}>Search</h1>
<div className={s.searchBar}>
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
<input
ref={inputRef}
className={s.searchInput}
placeholder="Search across sources…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && runSearch()}
autoFocus
/>
<button
className={s.searchBtn}
onClick={runSearch}
disabled={!query.trim() || loadingSources}
>
{loadingSources
? <CircleNotch size={13} weight="light" className="anim-spin" />
: "Search"}
</button>
</div>
</div>
<div className={s.langBar}>
{langs.map((l) => (
<button
key={l}
onClick={() => setActiveLang(l)}
className={[s.langBtn, activeLang === l ? s.langBtnActive : ""].join(" ").trim()}
>
{l === "preferred" ? `${preferredLang.toUpperCase()} (default)` : l === "all" ? "All" : l.toUpperCase()}
</button>
))}
{visibleSources.length > 0 && (
<span className={s.sourceCount}>{visibleSources.length} sources</span>
)}
</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}>
Searching {visibleSources.length} {activeLang === "preferred" ? `${preferredLang.toUpperCase()}` : activeLang === "all" ? "" : activeLang.toUpperCase()} source{visibleSources.length !== 1 ? "s" : ""}.
</p>
</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>
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
{!loading && mangas.length > 0 && (
<span className={s.resultCount}>{mangas.length} results</span>
)}
</div>
{error ? (
<p className={s.sourceError}>{error}</p>
) : loading ? (
<div className={s.sourceRow}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={s.skCard}>
<div className={["skeleton", s.skCover].join(" ")} />
<div className={["skeleton", s.skTitle].join(" ")} />
</div>
))}
</div>
) : mangas.length > 0 ? (
<div className={s.sourceRow}>
{mangas.slice(0, 8).map((m) => (
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
<div className={s.coverWrap}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
{m.inLibrary && <span className={s.inLibBadge}>In Library</span>}
</div>
<p className={s.cardTitle}>{m.title}</p>
</button>
))}
</div>
) : null}
</div>
))}
{allDone && !hasResults && submitted && (
<div className={s.empty}>
<p className={s.emptyText}>No results for "{submitted}"</p>
</div>
)}
</div>
)}
</div>
);
}
@@ -1,876 +0,0 @@
.root {
display: flex;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
/* ── Sidebar ── */
.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); }
.coverWrap {
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; }
.metaSkeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
.skLine { 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);
}
.statusBadge {
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;
}
.statusOngoing {
background: var(--accent-muted);
color: var(--accent-fg);
border: 1px solid var(--accent-dim);
}
.statusEnded {
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);
}
.sourceLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.description {
font-size: var(--text-xs);
color: var(--text-muted);
line-height: var(--leading-base);
display: -webkit-box;
-webkit-line-clamp: 8;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Progress ── */
.progressSection {
display: flex;
flex-direction: column;
gap: var(--sp-1);
}
.progressHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.progressLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.progressPct {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--accent-fg);
letter-spacing: var(--tracking-wide);
}
.progressTrack {
height: 3px;
background: var(--border-base);
border-radius: var(--radius-full);
overflow: hidden;
}
.progressFill {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.4s ease;
}
/* ── Actions ── */
.actions {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.libraryBtn {
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;
}
.libraryBtn:hover { border-color: var(--accent); color: var(--accent-fg); }
.libraryBtn:disabled { opacity: 0.4; cursor: default; }
.libraryBtnActive {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.externalLink {
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);
}
.externalLink:hover { color: var(--text-muted); border-color: var(--border-strong); }
/* ── Start/Continue reading button ── */
.readBtn {
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;
}
.readBtn:hover { background: var(--accent-muted); border-color: var(--accent-bright); }
.chapterCount {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
margin-top: auto;
padding-top: var(--sp-2);
}
/* ── Chapter list ── */
.listWrap {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.listHeader {
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;
}
.sortBtn {
display: flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
letter-spacing: var(--tracking-wide);
padding: 4px 8px;
border-radius: var(--radius-md);
transition: background var(--t-base), color var(--t-base);
}
.sortBtn:hover { background: var(--bg-raised); color: var(--text-secondary); }
.pagination {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.paginationBottom {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--border-dim);
flex-shrink: 0;
}
.pageBtn {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
letter-spacing: var(--tracking-wide);
padding: 3px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: none;
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.pageBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
.pageBtn:disabled { opacity: 0.3; cursor: default; }
.pageNum {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
min-width: 40px;
text-align: center;
}
.list {
flex: 1;
overflow-y: auto;
padding: var(--sp-2) var(--sp-4);
display: flex;
flex-direction: column;
gap: 1px;
}
.rowSkeleton {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: 12px var(--sp-3);
border-radius: var(--radius-md);
background: var(--bg-raised);
margin-bottom: 1px;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
background: none;
border: none;
border-radius: var(--radius-md);
padding: 10px var(--sp-3);
cursor: pointer;
text-align: left;
width: 100%;
color: var(--text-primary);
transition: background var(--t-fast);
}
.row:hover { background: var(--bg-raised); }
.rowRead .chName { color: var(--text-faint); }
.chLeft {
display: flex;
flex-direction: column;
gap: 3px;
overflow: hidden;
flex: 1;
min-width: 0;
}
.chName {
font-size: var(--text-base);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color var(--t-fast);
}
.row:hover .chName { color: var(--text-primary); }
.chMeta { display: flex; align-items: center; gap: var(--sp-3); }
.chMetaItem {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.chRight {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-shrink: 0;
margin-left: var(--sp-3);
}
.bookmarkIcon { color: var(--accent); }
.readIcon { color: var(--text-faint); }
.downloadedIcon { color: var(--accent-fg); }
.enqueuingIcon { color: var(--text-faint); }
.dlBtn {
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);
}
.dlBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
/* ── Download section ── */
.downloadSection {
position: relative; margin-top: var(--sp-2);
}
.downloadToggle {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 7px var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: none; color: var(--text-muted);
font-size: var(--text-sm); cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.downloadToggle:hover { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
.downloadMenu {
margin-top: var(--sp-1);
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-lg); padding: var(--sp-1);
display: flex; flex-direction: column; gap: 1px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
animation: fadeIn 0.1s ease both;
}
.dlItem {
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);
}
.dlItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
.dlItemSub { font-size: var(--text-xs); color: var(--text-faint); }
/* ── Details section ── */
.detailsSection {
margin-top: var(--sp-2);
border-top: 1px solid var(--border-dim);
padding-top: var(--sp-2);
}
.detailsToggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px var(--sp-1);
border-radius: var(--radius-md);
background: none;
border: none;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.detailsToggle:hover { color: var(--text-muted); background: var(--bg-raised); }
.caretClosed { transition: transform var(--t-base); }
.caretOpen { transform: rotate(180deg); transition: transform var(--t-base); }
.detailsBody {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-1);
}
.detailRow {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--sp-2);
}
.detailKey {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
}
.detailVal {
font-size: var(--text-xs);
color: var(--text-muted);
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detailMono {
font-family: monospace;
font-size: var(--text-2xs);
color: var(--text-faint);
}
.migrateBtn {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 6px var(--sp-2);
margin-top: var(--sp-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: none;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.migrateBtn:hover {
color: var(--text-secondary);
border-color: var(--border-strong);
background: var(--bg-raised);
}
/* ── List header right controls ── */
.listHeaderRight {
display: flex; align-items: center; gap: var(--sp-2);
}
/* ── Download dropdown (in list header) ── */
.dlWrap { position: relative; }
.dlToggleBtn {
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);
}
.dlToggleBtn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.dlDropdown {
position: absolute; top: calc(100% + 4px); right: 0;
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-lg); padding: var(--sp-1);
display: flex; flex-direction: column; gap: 1px;
min-width: 180px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
animation: scaleIn 0.1s ease both; transform-origin: top right;
z-index: 50;
}
/* ── Jump to chapter (in list header) ── */
.jumpWrap { position: relative; }
.jumpToggle {
padding: 4px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: none; color: var(--text-faint);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
cursor: pointer; white-space: nowrap;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.jumpToggle:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.jumpRow { display: flex; align-items: center; gap: 4px; }
.jumpInput {
width: 72px; padding: 4px 8px;
background: var(--bg-raised); border: 1px solid var(--border-focus);
border-radius: var(--radius-sm); color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs);
outline: none;
}
.jumpCancel {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: var(--radius-sm);
color: var(--text-faint); font-size: 10px; background: none;
transition: color var(--t-base), background var(--t-base);
}
.jumpCancel:hover { color: var(--text-muted); background: var(--bg-raised); }
/* ── View mode toggle ── */
.viewToggleBtn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
color: var(--text-faint);
background: none;
cursor: pointer;
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.viewToggleBtn:hover { color: var(--text-muted); background: var(--bg-raised); border-color: var(--border-dim); }
.viewToggleActive { color: var(--accent-fg) !important; background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
/* ── Chapter grid ── */
.grid {
flex: 1;
overflow-y: auto;
padding: var(--sp-3) var(--sp-4);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 5px;
align-content: start;
}
.gridCell {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg-raised);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
transition: border-color var(--t-fast), background var(--t-fast), transform var(--t-fast);
}
.gridCell:hover {
border-color: var(--accent);
background: var(--bg-overlay);
transform: scale(1.04);
z-index: 1;
}
/* Unread — subtle, inviting */
.gridCellNum {
font-family: var(--font-ui);
font-size: var(--text-2xs);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-tight);
color: var(--text-secondary);
line-height: 1;
position: relative;
z-index: 1;
}
/* Read — dimmed, clearly consumed */
.gridCellRead {
background: var(--bg-base);
border-color: var(--border-dim);
}
.gridCellRead .gridCellNum {
color: var(--text-faint);
}
.gridCellRead::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(135deg, transparent 60%, rgba(var(--accent-rgb, 100 130 255) / 0.08) 100%);
pointer-events: none;
}
/* In-progress — accent highlight on bottom edge */
.gridCellInProgress {
border-color: var(--accent-dim);
background: var(--bg-raised);
}
.gridCellInProgress .gridCellNum {
color: var(--accent-fg);
}
.gridCellInProgress::before {
content: "";
position: absolute; bottom: 0; left: 0; right: 0;
height: 3px;
background: var(--accent);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
/* Read indicator dot (top-right corner) */
.gridCellDot {
position: absolute; top: 3px; right: 3px;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--text-faint);
}
/* Bookmark indicator dot */
.gridCellBookmarked { border-color: var(--accent-dim); }
.gridCellBookmarkDot {
position: absolute; top: 3px; left: 3px;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--accent);
}
/* Spinner overlay for enqueueing */
.gridCellSpinner {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.3);
color: var(--text-faint);
}
/* Skeleton for grid loading state */
.gridCellSkeleton {
aspect-ratio: 1;
border-radius: var(--radius-sm);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
display: flex; align-items: center; justify-content: center;
padding: var(--sp-2);
}
/* ── Folder picker (icon button in list header) ──────────────────────── */
.folderPickerWrap {
position: relative;
}
/* Matches dlToggleBtn / viewToggleBtn style */
.folderPickerBtn {
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);
}
.folderPickerBtn:hover {
color: var(--text-secondary);
border-color: var(--border-strong);
background: var(--bg-raised);
}
/* Active state when manga is assigned to at least one folder */
.folderPickerBtnActive {
color: var(--accent-fg);
border-color: var(--accent-dim);
background: var(--accent-muted);
}
.folderPickerBtnActive:hover {
background: var(--accent-muted);
border-color: var(--accent);
color: var(--accent-fg);
}
.folderPickerMenu {
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;
}
.folderPickerEmpty {
padding: var(--sp-2) var(--sp-3);
font-size: var(--text-xs);
color: var(--text-faint);
}
.folderPickerItem {
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);
}
.folderPickerItem:hover { background: var(--bg-overlay); }
.folderPickerItemActive { color: var(--accent-fg); }
.folderPickerItemCheck {
width: 12px;
font-size: var(--text-xs);
color: var(--accent-fg);
flex-shrink: 0;
}
.folderPickerDivider {
height: 1px;
background: var(--border-dim);
margin: var(--sp-1) var(--sp-2);
}
.folderPickerCreate {
display: flex;
align-items: center;
gap: var(--sp-1);
padding: 4px var(--sp-2);
}
.folderPickerInput {
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;
}
.folderPickerInput:focus { border-color: var(--border-focus); }
.folderPickerConfirm {
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;
flex-shrink: 0;
}
.folderPickerConfirm:disabled { opacity: 0.4; cursor: default; }
.folderPickerCancel {
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;
flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base);
}
.folderPickerCancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
.folderPickerNewBtn {
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);
}
.folderPickerNewBtn:hover { color: var(--text-secondary); background: var(--bg-overlay); }
/* ── Delete all downloads button (in details section) ─────────────────── */
.deleteAllBtn {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
margin-top: var(--sp-2);
padding: 7px var(--sp-3);
border-radius: var(--radius-md);
font-size: var(--text-xs);
color: var(--color-error);
background: none;
border: 1px solid var(--color-error);
cursor: pointer;
text-align: left;
transition: background var(--t-base);
}
.deleteAllBtn:hover:not(:disabled) { background: var(--color-error-bg); }
.deleteAllBtn:disabled { opacity: 0.4; cursor: default; }
/* ── Danger item in dl dropdown ─────────────────────────────────────── */
.dlItemDanger {
color: var(--color-error) !important;
}
.dlItemDanger:hover:not(:disabled) {
background: var(--color-error-bg) !important;
}
+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>
-744
View File
@@ -1,744 +0,0 @@
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import {
ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle,
ArrowSquareOut, BookOpen, CircleNotch, Play,
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple,
} from "@phosphor-icons/react";
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 { useStore } from "../../store";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import MigrateModal from "./MigrateModal";
import type { Manga, Chapter } from "../../lib/types";
import s from "./SeriesDetail.module.css";
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" });
}
interface CtxState {
x: number;
y: number;
chapter: Chapter;
indexInSorted: number;
}
const CHAPTERS_PER_PAGE = 25;
// ── Folder picker (icon button for list header) ───────────────────────────────
function FolderPicker({ mangaId }: { mangaId: number }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const folders = useStore((st) => st.settings.folders);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
const addFolder = useStore((st) => st.addFolder);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const assigned = folders.filter((f) => f.mangaIds.includes(mangaId));
const hasAssigned = assigned.length > 0;
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setCreating(false);
setNewName("");
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
function handleCreate() {
const name = newName.trim();
if (!name) return;
const id = addFolder(name);
assignMangaToFolder(id, mangaId);
setNewName("");
setCreating(false);
}
return (
<div className={s.folderPickerWrap} ref={ref}>
<button
className={[s.folderPickerBtn, hasAssigned ? s.folderPickerBtnActive : ""].join(" ")}
onClick={() => setOpen((p) => !p)}
title={hasAssigned ? `Folders: ${assigned.map((f) => f.name).join(", ")}` : "Add to folder"}
>
<FolderSimplePlus size={14} weight={hasAssigned ? "fill" : "light"} />
</button>
{open && (
<div className={s.folderPickerMenu}>
{folders.length === 0 && !creating && (
<p className={s.folderPickerEmpty}>No folders yet</p>
)}
{folders.map((folder) => {
const isIn = folder.mangaIds.includes(mangaId);
return (
<button
key={folder.id}
className={[s.folderPickerItem, isIn ? s.folderPickerItemActive : ""].join(" ")}
onClick={() =>
isIn
? removeMangaFromFolder(folder.id, mangaId)
: assignMangaToFolder(folder.id, mangaId)
}
>
<span className={s.folderPickerItemCheck}>{isIn ? "✓" : ""}</span>
{folder.name}
</button>
);
})}
<div className={s.folderPickerDivider} />
{creating ? (
<div className={s.folderPickerCreate}>
<input
autoFocus
className={s.folderPickerInput}
placeholder="Folder name…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
if (e.key === "Escape") { setCreating(false); setNewName(""); }
}}
/>
<button className={s.folderPickerConfirm} onClick={handleCreate} disabled={!newName.trim()}>
Add
</button>
<button className={s.folderPickerCancel} onClick={() => { setCreating(false); setNewName(""); }}>
<X size={12} weight="light" />
</button>
</div>
) : (
<button className={s.folderPickerNewBtn} onClick={() => setCreating(true)}>
+ New folder
</button>
)}
</div>
)}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function SeriesDetail() {
const activeManga = useStore((state) => state.activeManga);
const setActiveManga = useStore((state) => state.setActiveManga);
const openReader = useStore((state) => state.openReader);
const settings = useStore((state) => state.settings);
const updateSettings = useStore((state) => state.updateSettings);
const [manga, setManga] = useState<Manga | null>(activeManga);
const [chapters, setChapters] = useState<Chapter[]>([]);
const [loadingManga, setLoadingManga] = useState(true);
const [loadingChapters, setLoadingChapters] = useState(true);
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
const [dlOpen, setDlOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [migrateOpen, setMigrateOpen] = useState(false);
const [togglingLibrary, setTogglingLibrary] = useState(false);
const [chapterPage, setChapterPage] = useState(1);
const [ctx, setCtx] = useState<CtxState | null>(null);
const [jumpOpen, setJumpOpen] = useState(false);
const [jumpInput, setJumpInput] = useState("");
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [deletingAll, setDeletingAll] = useState(false);
const sortDir = settings.chapterSortDir;
useEffect(() => {
if (!activeManga) return;
setLoadingManga(true);
gql<{ manga: Manga }>(GET_MANGA, { id: activeManga.id })
.then((data) => setManga(data.manga))
.catch(console.error)
.finally(() => setLoadingManga(false));
}, [activeManga?.id]);
const loadChapters = useCallback((mangaId: number) => {
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId })
.then((data) => {
const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
setChapters(sorted);
return sorted;
});
}, []);
useEffect(() => {
if (!activeManga) return;
setLoadingChapters(true);
setChapters([]);
setChapterPage(1);
loadChapters(activeManga.id)
.catch(console.error)
.finally(() => setLoadingChapters(false));
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
.then(() => loadChapters(activeManga.id))
.catch(console.error);
}, [activeManga?.id]);
const sortedChapters = useMemo(() =>
sortDir === "desc" ? [...chapters].reverse() : [...chapters],
[chapters, sortDir]
);
const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE);
const pageChapters = sortedChapters.slice(
(chapterPage - 1) * CHAPTERS_PER_PAGE,
chapterPage * CHAPTERS_PER_PAGE
);
const readCount = chapters.filter((c) => c.isRead).length;
const totalCount = chapters.length;
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
const continueChapter = useMemo(() => {
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 };
}, [chapters]);
async function toggleLibrary() {
if (!manga) return;
setTogglingLibrary(true);
const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
setManga((prev) => prev ? { ...prev, inLibrary: next } : prev);
setTogglingLibrary(false);
}
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
e.stopPropagation();
setEnqueueing((prev) => new Set(prev).add(chapter.id));
await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error);
setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; });
if (activeManga) loadChapters(activeManga.id);
}
async function markRead(chapterId: number, isRead: boolean) {
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isRead } : c));
}
async function markAllAboveRead(indexInSorted: number) {
const targets = sortedChapters.slice(0, indexInSorted + 1);
const ids = targets.filter((c) => !c.isRead).map((c) => c.id);
if (!ids.length) return;
await gql(MARK_CHAPTERS_READ, { ids, isRead: true }).catch(console.error);
setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead: true } : c));
}
async function deleteDownloaded(chapterId: number) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c));
}
async function deleteAllDownloads() {
const ids = chapters.filter((c) => c.isDownloaded).map((c) => c.id);
if (!ids.length) return;
setDeletingAll(true);
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
setChapters((prev) => prev.map((c) => ({ ...c, isDownloaded: false })));
setDeletingAll(false);
}
async function enqueueMultiple(chapterIds: number[]) {
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
if (activeManga) loadChapters(activeManga.id);
}
function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) {
e.preventDefault();
setCtx({ x: e.clientX, y: e.clientY, chapter, indexInSorted });
}
function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] {
return [
{
label: ch.isRead ? "Mark as unread" : "Mark as read",
icon: ch.isRead
? <Circle size={13} weight="light" />
: <CheckCircle size={13} weight="light" />,
onClick: () => markRead(ch.id, !ch.isRead),
},
{
label: "Mark all above as read",
icon: <CheckCircle size={13} weight="duotone" />,
onClick: () => markAllAboveRead(indexInSorted),
disabled: indexInSorted === 0,
},
{ separator: true },
{
label: ch.isDownloaded ? "Delete download" : "Download",
icon: ch.isDownloaded
? <Trash size={13} weight="light" />
: <Download size={13} weight="light" />,
onClick: () => ch.isDownloaded
? deleteDownloaded(ch.id)
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
danger: ch.isDownloaded,
},
{ separator: true },
{
label: "Download all from here",
icon: <DownloadSimple size={13} weight="light" />,
onClick: () => {
const fromHere = sortedChapters
.slice(indexInSorted)
.filter((c) => !c.isDownloaded)
.map((c) => c.id);
enqueueMultiple(fromHere);
},
},
];
}
if (!activeManga) return null;
const statusLabel = manga?.status
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
: null;
return (
<div className={s.root} onContextMenu={(e) => e.preventDefault()}>
{/* ── Sidebar ── */}
<div className={s.sidebar}>
<button className={s.back} onClick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" />
<span>Library</span>
</button>
<div className={s.coverWrap}>
<img src={thumbUrl(activeManga.thumbnailUrl)} alt={activeManga.title} className={s.cover} />
</div>
{loadingManga ? (
<div className={s.metaSkeleton}>
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "90%", height: 14 }} />
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "60%", height: 11 }} />
</div>
) : (
<div className={s.meta}>
<p className={s.title}>{manga?.title}</p>
{(manga?.author || manga?.artist) && (
<p className={s.byline}>
{[manga.author, manga.artist]
.filter(Boolean)
.filter((v, i, a) => a.indexOf(v) === i)
.join(" · ")}
</p>
)}
{statusLabel && (
<span className={[s.statusBadge, manga?.status === "ONGOING" ? s.statusOngoing : s.statusEnded].join(" ").trim()}>
{statusLabel}
</span>
)}
{manga?.genre && manga.genre.length > 0 && (
<div className={s.genres}>
{manga.genre.map((g) => <span key={g} className={s.genre}>{g}</span>)}
</div>
)}
{manga?.description && <p className={s.description}>{manga.description}</p>}
</div>
)}
{/* Progress bar */}
{totalCount > 0 && (
<div className={s.progressSection}>
<div className={s.progressHeader}>
<span className={s.progressLabel}>{readCount} / {totalCount} read</span>
<span className={s.progressPct}>{Math.round(progressPct)}%</span>
</div>
<div className={s.progressTrack}>
<div className={s.progressFill} style={{ width: `${progressPct}%` }} />
</div>
</div>
)}
<div className={s.actions}>
<button
className={[s.libraryBtn, manga?.inLibrary ? s.libraryBtnActive : ""].join(" ").trim()}
onClick={toggleLibrary}
disabled={togglingLibrary || loadingManga}
>
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
{manga?.inLibrary ? "In Library" : "Add to Library"}
</button>
{manga?.realUrl && (
<a href={manga.realUrl} target="_blank" rel="noreferrer" className={s.externalLink}>
<ArrowSquareOut size={13} weight="light" />
</a>
)}
</div>
{/* Folder picker moved to chapter list header */}
{continueChapter && (
<button
className={s.readBtn}
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>
)}
<p className={s.chapterCount}>
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
</p>
{/* ── Details (collapsible) ── */}
{!loadingManga && manga?.source && (
<div className={s.detailsSection}>
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
<span>Details</span>
<CaretDown size={11} weight="light" className={detailsOpen ? s.caretOpen : s.caretClosed} />
</button>
{detailsOpen && (
<div className={s.detailsBody}>
<div className={s.detailRow}>
<span className={s.detailKey}>Source</span>
<span className={s.detailVal}>{manga.source.displayName}</span>
</div>
<div className={s.detailRow}>
<span className={s.detailKey}>Language</span>
<span className={s.detailVal}>{manga.source.name.match(/\(([^)]+)\)$/)?.[1] ?? "—"}</span>
</div>
<div className={s.detailRow}>
<span className={s.detailKey}>Source ID</span>
<span className={[s.detailVal, s.detailMono].join(" ")}>{manga.source.id}</span>
</div>
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
<ArrowsClockwise size={12} weight="light" />
Switch source
</button>
{/* Delete all downloads */}
{downloadedCount > 0 && (
<button
className={s.deleteAllBtn}
onClick={deleteAllDownloads}
disabled={deletingAll}
>
<Trash size={12} weight="light" />
{deletingAll ? "Deleting…" : `Delete all downloads (${downloadedCount})`}
</button>
)}
</div>
)}
</div>
)}
</div>
{/* ── Chapter list ── */}
<div className={s.listWrap}>
<div className={s.listHeader}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)" }}>
<button
className={s.sortBtn}
onClick={() => {
updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" });
setChapterPage(1);
}}
title={sortDir === "desc" ? "Newest first" : "Oldest first"}
>
{sortDir === "desc"
? <SortDescending size={14} weight="light" />
: <SortAscending size={14} weight="light" />
}
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
</button>
<button
className={[s.viewToggleBtn, viewMode === "grid" ? s.viewToggleActive : ""].join(" ")}
onClick={() => setViewMode((v) => v === "list" ? "grid" : "list")}
title={viewMode === "list" ? "Switch to grid view" : "Switch to list view"}
>
{viewMode === "list"
? <SquaresFour size={14} weight="light" />
: <List size={14} weight="light" />
}
</button>
</div>
<div className={s.listHeaderRight}>
{/* Folder picker */}
{activeManga && <FolderPicker mangaId={activeManga.id} />}
{/* Jump to chapter */}
{chapters.length > 1 && (
<div className={s.jumpWrap}>
{!jumpOpen ? (
<button className={s.jumpToggle} onClick={() => { setJumpOpen(true); setJumpInput(""); }}>
Go to
</button>
) : (
<div className={s.jumpRow}>
<input
className={s.jumpInput}
type="text"
placeholder="Ch. #"
value={jumpInput}
autoFocus
onChange={(e) => setJumpInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") { setJumpOpen(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);
}
setJumpOpen(false);
}
}}
/>
<button className={s.jumpCancel} onClick={() => setJumpOpen(false)}></button>
</div>
)}
</div>
)}
{/* Download menu */}
{chapters.length > 0 && (
<div className={s.dlWrap}>
<button className={s.dlToggleBtn} onClick={() => setDlOpen((p) => !p)}>
<Download size={13} weight="light" />
</button>
{dlOpen && (
<div className={s.dlDropdown}>
{continueChapter && (
<button className={s.dlItem}
onClick={() => {
const from = sortedChapters.indexOf(continueChapter.chapter);
const ids = sortedChapters.slice(from).filter((c) => !c.isDownloaded).map((c) => c.id);
enqueueMultiple(ids);
setDlOpen(false);
}}>
<span>From current</span>
<span className={s.dlItemSub}>Ch.{continueChapter.chapter.chapterNumber} onwards</span>
</button>
)}
<button className={s.dlItem}
onClick={() => {
const ids = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).map((c) => c.id);
enqueueMultiple(ids);
setDlOpen(false);
}}>
<span>Unread chapters</span>
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).length} remaining</span>
</button>
<button className={s.dlItem}
onClick={() => {
const ids = sortedChapters.filter((c) => !c.isDownloaded).map((c) => c.id);
enqueueMultiple(ids);
setDlOpen(false);
}}>
<span>Download all</span>
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span>
</button>
{downloadedCount > 0 && (
<>
<div style={{ height: 1, background: "var(--border-dim)", margin: "var(--sp-1) var(--sp-2)" }} />
<button className={[s.dlItem, s.dlItemDanger].join(" ")}
onClick={() => { deleteAllDownloads(); setDlOpen(false); }}
disabled={deletingAll}
>
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
<span className={s.dlItemSub}>{downloadedCount} downloaded</span>
</button>
</>
)}
</div>
)}
</div>
)}
{totalPages > 1 && (
<div className={s.pagination}>
<button
className={s.pageBtn}
onClick={() => setChapterPage((p) => Math.max(1, p - 1))}
disabled={chapterPage === 1}
></button>
<span className={s.pageNum}>{chapterPage} / {totalPages}</span>
<button
className={s.pageBtn}
onClick={() => setChapterPage((p) => Math.min(totalPages, p + 1))}
disabled={chapterPage === totalPages}
></button>
</div>
)}
</div>
</div>
<div className={viewMode === "grid" ? s.grid : s.list}>
{loadingChapters && chapters.length === 0 ? (
viewMode === "grid" ? (
Array.from({ length: 24 }).map((_, i) => (
<div key={i} className={s.gridCellSkeleton}>
<div className="skeleton" style={{ width: "60%", height: 10, borderRadius: 3 }} />
</div>
))
) : (
Array.from({ length: 8 }).map((_, i) => (
<div key={i} className={s.rowSkeleton}>
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "55%", height: 12 }} />
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "25%", height: 11 }} />
</div>
))
)
) : viewMode === "grid" ? (
sortedChapters.map((ch) => {
const idxInSorted = sortedChapters.indexOf(ch);
const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0;
return (
<button
key={ch.id}
className={[
s.gridCell,
ch.isRead ? s.gridCellRead : "",
inProgress ? s.gridCellInProgress : "",
ch.isBookmarked ? s.gridCellBookmarked : "",
].filter(Boolean).join(" ")}
onClick={() => openReader(ch, sortedChapters)}
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
title={ch.name}
>
<span className={s.gridCellNum}>
{ch.chapterNumber % 1 === 0
? ch.chapterNumber.toFixed(0)
: ch.chapterNumber.toString()}
</span>
{ch.isRead && <span className={s.gridCellDot} />}
{inProgress && <span className={s.gridCellProgress} style={{ width: `${Math.min(100, ((ch.lastPageRead ?? 0) / 1) * 100)}%` }} />}
{ch.isBookmarked && <span className={s.gridCellBookmarkDot} />}
{enqueueing.has(ch.id) && (
<span className={s.gridCellSpinner}>
<CircleNotch size={10} weight="light" className="anim-spin" />
</span>
)}
</button>
);
})
) : (
pageChapters.map((ch) => {
const idxInSorted = sortedChapters.indexOf(ch);
return (
<button
key={ch.id}
className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()}
onClick={() => openReader(ch, sortedChapters)}
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
>
<div className={s.chLeft}>
<span className={s.chName}>{ch.name}</span>
<div className={s.chMeta}>
{ch.scanlator && <span className={s.chMetaItem}>{ch.scanlator}</span>}
{ch.uploadDate && <span className={s.chMetaItem}>{formatDate(ch.uploadDate)}</span>}
{ch.lastPageRead != null && ch.lastPageRead > 0 && !ch.isRead && (
<span className={s.chMetaItem}>p.{ch.lastPageRead}</span>
)}
</div>
</div>
<div className={s.chRight}>
{ch.isBookmarked && (
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
)}
{ch.isRead ? (
<CheckCircle size={14} weight="light" className={s.readIcon} />
) : ch.isDownloaded ? (
<BookOpen size={14} weight="light" className={s.downloadedIcon} />
) : enqueueing.has(ch.id) ? (
<CircleNotch size={14} weight="light" className={[s.enqueuingIcon, "anim-spin"].join(" ")} />
) : (
<button className={s.dlBtn} onClick={(e) => enqueue(ch, e)} title="Download">
<Download size={13} weight="light" />
</button>
)}
</div>
</button>
);
})
)}
</div>
{totalPages > 1 && (
<div className={s.paginationBottom}>
<button
className={s.pageBtn}
onClick={() => setChapterPage((p) => Math.max(1, p - 1))}
disabled={chapterPage === 1}
> Prev</button>
<span className={s.pageNum}>{chapterPage} / {totalPages}</span>
<button
className={s.pageBtn}
onClick={() => setChapterPage((p) => Math.min(totalPages, p + 1))}
disabled={chapterPage === totalPages}
>Next </button>
</div>
)}
</div>
{ctx && (
<ContextMenu
x={ctx.x}
y={ctx.y}
items={buildCtxItems(ctx.chapter, ctx.indexInSorted)}
onClose={() => setCtx(null)}
/>
)}
{migrateOpen && manga && (
<MigrateModal
manga={manga}
currentChapters={chapters}
onClose={() => setMigrateOpen(false)}
onMigrated={(newManga: Manga) => {
setMigrateOpen(false);
setActiveManga(newManga);
}}
/>
)}
</div>
);
}
+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>
-461
View File
@@ -1,461 +0,0 @@
/* ─── 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(720px, calc(100vw - 48px));
height: min(520px, 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);
}
/* ─── Sidebar ── */
.sidebar {
width: 152px;
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-3);
gap: var(--sp-1);
}
.modalTitle {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
padding: 0 var(--sp-2) var(--sp-3);
}
.nav { display: flex; flex-direction: column; gap: 1px; }
.navItem {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 7px var(--sp-2);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
transition: background var(--t-fast), color var(--t-fast);
}
.navItem:hover { background: var(--bg-overlay); color: var(--text-secondary); }
.navActive { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
.navActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
/* ─── Content ── */
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.contentHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.contentTitle {
font-size: var(--text-md);
font-weight: var(--weight-medium);
color: var(--text-secondary);
letter-spacing: var(--tracking-tight);
}
.closeBtn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
}
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
.contentBody { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); }
/* ─── Panel / Section ── */
.panel { display: flex; flex-direction: column; gap: var(--sp-6); }
.section { display: flex; flex-direction: column; gap: 1px; }
.sectionTitle {
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);
}
/* ─── Toggle ── */
.toggleRow {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-4); padding: 10px var(--sp-3); border-radius: var(--radius-md);
cursor: pointer; transition: background var(--t-fast);
}
.toggleRow:hover { background: var(--bg-raised); }
.toggleInfo { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.toggleLabel { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-tight); }
.toggleDesc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-snug); }
.toggle {
position: relative; width: 34px; height: 18px; border-radius: var(--radius-full);
background: var(--bg-subtle); border: 1px solid var(--border-strong); flex-shrink: 0;
cursor: pointer; transition: background var(--t-base), border-color var(--t-base);
}
.toggleOn { background: var(--accent-dim); border-color: var(--accent); }
.toggleThumb {
position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
border-radius: 50%; background: var(--text-faint);
transition: transform var(--t-base), background var(--t-base);
}
.toggleOn .toggleThumb { transform: translateX(16px); background: var(--accent-fg); }
/* ─── Stepper ── */
.stepRow {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-4); padding: 10px var(--sp-3); border-radius: var(--radius-md);
transition: background var(--t-fast);
}
.stepRow:hover { background: var(--bg-raised); }
.stepControls { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.stepBtn {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); font-size: var(--text-base);
color: var(--text-muted); transition: background var(--t-base), color var(--t-base);
line-height: 1;
}
.stepBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
.stepBtn:disabled { opacity: 0.25; cursor: default; }
.stepVal {
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary);
min-width: 28px; text-align: center; letter-spacing: var(--tracking-wide);
}
/* ─── Select (custom) ── */
.selectWrap { position: relative; flex-shrink: 0; min-width: 130px; }
.selectBtn {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
width: 100%; padding: 5px 10px;
background: var(--bg-raised); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
cursor: pointer; transition: border-color var(--t-base), background var(--t-base);
text-align: left;
}
.selectBtn:hover { border-color: var(--border-focus); }
.selectCaret {
color: var(--text-faint); flex-shrink: 0;
transition: transform var(--t-base);
}
.selectCaretOpen { transform: rotate(180deg); }
.selectMenu {
position: absolute; top: 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: 200; animation: scaleIn 0.1s ease both; transform-origin: top center;
}
.selectOption {
padding: 6px 10px; border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
color: var(--text-secondary); background: none; border: none;
cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.selectOption:hover { background: var(--bg-overlay); color: var(--text-primary); }
.selectOptionActive { color: var(--accent-fg); background: var(--accent-muted); }
.selectOptionActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
/* ─── Scale ── */
.scaleRow {
display: flex; align-items: center; gap: var(--sp-3);
padding: 10px var(--sp-3); border-radius: var(--radius-md);
}
.scaleSlider { flex: 1; }
.scaleVal {
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary);
min-width: 36px; text-align: right; letter-spacing: var(--tracking-wide);
}
.scaleHint {
display: flex; flex-wrap: wrap; gap: var(--sp-1);
padding: 0 var(--sp-3) var(--sp-2);
}
.scalePreset {
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);
}
.scalePreset:hover { color: var(--text-muted); border-color: var(--border-strong); }
.scalePresetActive {
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
}
/* ─── Text input ── */
.textInput {
background: var(--bg-raised); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
outline: none; flex-shrink: 0; width: 180px;
transition: border-color var(--t-base);
}
.textInput:focus { border-color: var(--border-focus); }
/* ─── Keybinds ── */
.kbHeader { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
.kbHint { font-size: var(--text-xs); color: var(--text-faint); padding: 0 var(--sp-3) var(--sp-3); }
.resetAllBtn {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.resetAllBtn:hover { color: var(--color-error); border-color: var(--color-error); }
.kbList { display: flex; flex-direction: column; gap: 1px; }
.kbRow {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-4); padding: 8px var(--sp-3); border-radius: var(--radius-md);
transition: background var(--t-fast);
}
.kbRow:hover { background: var(--bg-raised); }
.kbLabel { font-size: var(--text-sm); color: var(--text-secondary); flex: 1; }
.kbRight { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.kbBind {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-overlay);
color: var(--text-secondary); cursor: pointer; min-width: 100px; text-align: center;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.kbBind:hover { border-color: var(--accent); color: var(--accent-fg); }
.kbBindListening {
border-color: var(--accent); background: var(--accent-muted); color: var(--accent-fg);
animation: pulse 1s ease infinite;
}
.kbReset {
font-size: var(--text-base); color: var(--text-faint); width: 22px; height: 22px;
border-radius: var(--radius-sm); border: 1px solid transparent; background: none;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: color var(--t-base), border-color var(--t-base);
}
.kbReset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); }
.kbReset:disabled { opacity: 0.2; cursor: default; }
/* ─── Storage ── */
.storageLoading {
font-size: var(--text-sm); color: var(--text-faint);
padding: var(--sp-3) var(--sp-3);
}
.storageBarWrap { padding: var(--sp-2) var(--sp-3) var(--sp-1); }
.storageBar {
width: 100%; height: 7px;
background: var(--bg-overlay); border-radius: var(--radius-full);
overflow: hidden;
}
.storageBarFill {
height: 100%; border-radius: var(--radius-full);
background: var(--accent);
transition: width 0.4s ease;
}
.storageBarWarn { background: #d97706; }
.storageBarCritical { background: var(--color-error); }
.storageBarLabels {
display: flex; justify-content: space-between;
margin-top: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
}
.storageBarUsed { color: var(--text-secondary); }
.storageBarFree { color: var(--text-faint); }
.storageBarNote {
font-size: var(--text-xs); color: var(--text-faint);
margin-top: var(--sp-1);
}
.storageLegend {
display: flex; flex-direction: column; gap: 1px;
padding: var(--sp-2) var(--sp-3);
}
.storageLegendRow {
display: flex; align-items: center; gap: var(--sp-2);
padding: 6px 0;
font-size: var(--text-sm);
}
.storageDot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.storageDotManga { background: var(--accent); }
.storageDotApp { background: var(--border-strong); }
.storageDotFree { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
.storageLegendLabel { flex: 1; color: var(--text-muted); }
.storageLegendVal { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
.storageLimitHint {
font-size: var(--text-xs); color: #d97706;
padding: 0 var(--sp-3) var(--sp-2);
}
.setLimitBtn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 12px; border-radius: var(--radius-md);
background: none; border: 1px solid var(--border-strong);
color: var(--text-muted); cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base);
}
.setLimitBtn:hover { color: var(--text-primary); border-color: var(--border-focus); }
.storagePathNote {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
padding: var(--sp-1) var(--sp-3) var(--sp-2);
word-break: break-all;
}
/* ─── About ── */
.aboutBlock {
padding: var(--sp-3); background: var(--bg-raised); border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
}
.aboutLine { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-base); }
.dangerBtn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 12px; border-radius: var(--radius-md);
background: none; border: 1px solid var(--color-error);
color: var(--color-error); cursor: pointer; flex-shrink: 0;
transition: background var(--t-base);
}
.dangerBtn:hover:not(:disabled) { background: var(--color-error-bg); }
.dangerBtn:disabled { opacity: 0.3; cursor: default; }
/* ── Folder management (Settings FoldersTab) ────────────────────────── */
.folderCreateRow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) var(--sp-3) var(--sp-3);
}
.folderCreateBtn {
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: 5px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: none;
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base);
}
.folderCreateBtn:hover:not(:disabled) {
color: var(--accent-fg);
border-color: var(--accent);
}
.folderCreateBtn:disabled { opacity: 0.3; cursor: default; }
.folderList {
display: flex;
flex-direction: column;
gap: 1px;
}
.folderRow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 9px var(--sp-3);
border-radius: var(--radius-md);
transition: background var(--t-fast);
}
.folderRow:hover { background: var(--bg-raised); }
.folderRowName {
flex: 1;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.folderRowCount {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
margin-right: var(--sp-1);
}
.folderTabToggle {
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;
flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.folderTabToggle:hover {
color: var(--text-muted);
border-color: var(--border-strong);
}
.folderTabToggleOn {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.folderTabToggleOn:hover {
background: var(--accent-muted);
color: var(--accent-fg);
}
+946
View File
@@ -0,0 +1,946 @@
<script lang="ts">
import { tick } from "svelte";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "phosphor-svelte";
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { gql } from "../../lib/client";
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen } from "../../store/state.svelte";
import { cache } from "../../lib/cache";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
import type { Keybinds } from "../../lib/keybinds";
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
const TABS: { id: Tab; label: string; icon: any }[] = [
{ id: "general", label: "General", icon: Gear },
{ id: "appearance", label: "Appearance", icon: PaintBrush },
{ id: "reader", label: "Reader", icon: Book },
{ id: "library", label: "Library", icon: Image },
{ id: "performance",label: "Performance", icon: Sliders },
{ id: "keybinds", label: "Keybinds", icon: Keyboard },
{ id: "storage", label: "Storage", icon: HardDrives },
{ id: "folders", label: "Folders", icon: FolderSimple },
{ id: "about", label: "About", icon: Info },
{ id: "devtools", label: "Dev Tools", icon: Wrench },
];
const THEMES: { id: Theme; label: string; description: string; swatches: string[] }[] = [
{ id: "dark", label: "Dark", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] },
{ id: "high-contrast", label: "High Contrast", description: "Darker base, sharper text", swatches: ["#080808","#111111","#bcd8bc","#ffffff"] },
{ id: "light", label: "Light", description: "Warm off-white", swatches: ["#f4f2ee","#faf8f4","#2a5a2a","#1a1916"] },
{ id: "light-contrast", label: "Light Contrast", description: "Light with maximum contrast", swatches: ["#ece8e2","#f5f2ec","#183818","#080806"] },
{ id: "midnight", label: "Midnight", description: "Deep blue-black tint", swatches: ["#0c1020","#101428","#a8b4e8","#eeeef8"] },
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
];
let tab: Tab = $state("general");
let contentBodyEl: HTMLDivElement;
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })); });
function close() { setSettingsOpen(false); }
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && !listeningKey) close(); }
$effect(() => {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
let listeningKey: keyof Keybinds | null = $state(null);
function startListen(key: keyof Keybinds) {
listeningKey = listeningKey === key ? null : key;
}
function onKeyCapture(e: KeyboardEvent) {
if (!listeningKey) return;
e.preventDefault(); e.stopPropagation();
const bind = eventToKeybind(e);
if (!bind) return;
updateSettings({ keybinds: { ...store.settings.keybinds, [listeningKey]: bind } });
listeningKey = null;
}
$effect(() => {
if (listeningKey) {
window.addEventListener("keydown", onKeyCapture, true);
return () => window.removeEventListener("keydown", onKeyCapture, true);
}
});
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
let storageInfo: StorageInfo | null = $state(null);
let storageLoading = $state(false);
let storageError: string | null = $state(null);
let clearing = $state(false);
let cleared = $state(false);
async function fetchStorage() {
storageLoading = true; storageError = null;
try {
const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH);
storageInfo = await invoke<StorageInfo>("get_storage_info", { downloadsPath: pathData.settings.downloadsPath });
} catch (e: any) { storageError = e instanceof Error ? e.message : String(e); }
finally { storageLoading = false; }
}
$effect(() => { if (tab === "storage" && !storageInfo && !storageLoading) fetchStorage(); });
function handleClearCache() {
clearing = true;
caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))).catch(() => {})
.finally(() => { clearing = false; cleared = true; setTimeout(() => cleared = false, 2500); fetchStorage(); });
}
function fmtBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B","KB","MB","GB","TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
}
// ── Performance metrics ───────────────────────────────────────────────────────
// Pulled from the session cache on demand — lightweight, no extra fetches.
interface PerfSnapshot {
cacheEntries: number;
cacheKeys: string[];
oldestEntryMs: number | null;
newestEntryMs: number | null;
}
let perfSnapshot: PerfSnapshot | null = $state(null);
function refreshPerfMetrics() {
// cache.list() isn't exported, but we can probe known keys to build a snapshot
const knownPrefixes = ["library", "sources", "popular", "genre:", "manga:", "chapters:", "page:", "pages:"];
let entries = 0;
let oldest: number | null = null;
let newest: number | null = null;
const foundKeys: string[] = [];
// We walk the cache via ageOf — non-zero means the key exists
// For a real count we introspect via a set of likely keys
// (The cache module doesn't expose an iterator, so we sample)
const checkKey = (k: string) => {
const age = cache.ageOf(k);
if (age !== undefined) {
entries++;
foundKeys.push(k);
const ts = Date.now() - age;
if (oldest === null || ts < oldest) oldest = ts;
if (newest === null || ts > newest) newest = ts;
}
};
["library", "sources", "popular"].forEach(checkKey);
["Action","Romance","Fantasy","Comedy","Drama","Horror","Sci-Fi","Adventure","Thriller",
"Isekai","Supernatural","Historical","Psychological","Sports","Mystery","Mecha",
"Slice of Life","School Life","Martial Arts","Magic","Military"].forEach(g => checkKey(`genre:${g}`));
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest };
}
$effect(() => { if (tab === "performance") refreshPerfMetrics(); });
function fmtAge(ts: number | null): string {
if (ts === null) return "—";
const secs = Math.floor((Date.now() - ts) / 1000);
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
return `${Math.floor(mins / 60)}h ago`;
}
// Storage limit input state
let storageLimitInput = $state(String(store.settings.storageLimitGb ?? ""));
function applyStorageLimit() {
const v = storageLimitInput.trim();
if (v === "" || v === "0") { updateSettings({ storageLimitGb: null }); return; }
const n = parseFloat(v);
if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n });
}
let newFolderName = $state("");
let editingId: string | null = $state(null);
let editingName = $state("");
function createFolder() {
const name = newFolderName.trim();
if (!name) return;
addFolder(name); newFolderName = "";
}
function startEdit(id: string, name: string) { editingId = id; editingName = name; }
function commitEdit() {
if (editingId && editingName.trim()) renameFolder(editingId, editingName.trim());
editingId = null; editingName = "";
}
let selectOpen: string | null = $state(null);
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
function onSelectOutside(e: MouseEvent) {
if (selectOpen && !(e.target as HTMLElement).closest(".select-wrap")) selectOpen = null;
}
$effect(() => {
document.addEventListener("mousedown", onSelectOutside);
return () => document.removeEventListener("mousedown", onSelectOutside);
});
let splashTriggered = $state(false);
let appVersion = $state("…");
let latestVersion = $state<string | null>(null);
let checkingUpdate = $state(false);
let updateError = $state<string | null>(null);
$effect(() => {
if (tab === "about") {
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
}
});
async function checkForUpdate() {
checkingUpdate = true; updateError = null; latestVersion = null;
try {
const res = await fetch("https://api.github.com/repos/Youwes09/Moku/releases/latest", {
method: "GET",
headers: { "User-Agent": "Moku" },
});
const data = await res.json() as { tag_name: string };
latestVersion = data.tag_name.replace(/^v/, "");
} catch (e) {
updateError = "Could not reach GitHub";
} finally {
checkingUpdate = false;
}
}
function triggerSplash() {
splashTriggered = true;
setTimeout(() => splashTriggered = false, 200);
(window as any).__mokuShowSplash?.();
}
</script>
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="modal" role="dialog" aria-label="Settings">
<div class="sidebar">
<p class="modal-title">Settings</p>
<nav class="nav">
{#each TABS as t}
<button class="nav-item" class:active={tab === t.id} onclick={() => tab = t.id}>
<t.icon size={14} weight="light" />
<span>{t.label}</span>
</button>
{/each}
</nav>
</div>
<div class="content">
<div class="content-header">
<p class="content-title">{TABS.find((t) => t.id === tab)?.label}</p>
<button class="close-btn" aria-label="Close settings" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="content-body" bind:this={contentBodyEl}>
{#if tab === "general"}
<div class="panel">
<div class="section">
<p class="section-title">Interface Scale</p>
<div class="scale-row">
<input type="range" min={70} max={200} step={5} value={store.settings.uiScale}
oninput={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" />
<span class="scale-val">{store.settings.uiScale}%</span>
<button class="step-btn" onclick={() => updateSettings({ uiScale: 100 })} disabled={store.settings.uiScale === 100} title="Reset"></button>
</div>
<p class="scale-hint">
{#each [70,80,90,100,110,125,150,175,200] as v}
<button class="scale-preset" class:active={store.settings.uiScale === v} onclick={() => updateSettings({ uiScale: v })}>{v}%</button>
{/each}
</p>
</div>
<div class="section">
<p class="section-title">Server</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Server URL</span><span class="toggle-desc">Base URL of your Suwayomi instance</span></div>
<input class="text-input" value={store.settings.serverUrl ?? "http://localhost:4567"} oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
</div>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-start server</span><span class="toggle-desc">Launch tachidesk-server when Moku opens</span></div>
<button role="switch" aria-checked={store.settings.autoStartServer} aria-label="Auto-start server" class="toggle" class:on={store.settings.autoStartServer} onclick={() => updateSettings({ autoStartServer: !store.settings.autoStartServer })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Inactivity</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Idle screen timeout</span><span class="toggle-desc">Show the Moku idle splash after this much inactivity.</span></div>
<div class="select-wrap" id="idle-timeout">
<button class="select-btn" onclick={() => toggleSelect("idle-timeout")}>
<span>{{ "0":"Never","1":"1 minute","2":"2 minutes","5":"5 minutes","10":"10 minutes","15":"15 minutes","30":"30 minutes" }[String(store.settings.idleTimeoutMin ?? 5)] ?? `${store.settings.idleTimeoutMin} min`}</span>
<svg class="select-caret" class:open={selectOpen === "idle-timeout"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "idle-timeout"}
<div class="select-menu">
{#each [["0","Never"],["1","1 minute"],["2","2 minutes"],["5","5 minutes"],["10","10 minutes"],["15","15 minutes"],["30","30 minutes"]] as [v, l]}
<button class="select-option" class:active={String(store.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
{:else if tab === "appearance"}
<div class="panel">
<div class="section">
<p class="section-title">Theme</p>
<div class="theme-grid">
{#each THEMES as theme}
{@const active = (store.settings.theme ?? "dark") === theme.id}
<button class="theme-card" class:active onclick={() => updateSettings({ theme: theme.id })} title={theme.description}>
<div class="theme-preview">
<div class="theme-preview-bg" style="background:{theme.swatches[0]}">
<div class="theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
<div class="theme-preview-content">
<div class="theme-preview-accent" style="background:{theme.swatches[2]}"></div>
<div class="theme-preview-text" style="background:{theme.swatches[3]}55"></div>
<div class="theme-preview-text" style="background:{theme.swatches[3]}33;width:60%"></div>
</div>
</div>
</div>
<div class="theme-card-info">
<span class="theme-card-label">{theme.label}</span>
<span class="theme-card-desc">{theme.description}</span>
</div>
{#if active}<span class="theme-card-check"></span>{/if}
</button>
{/each}
</div>
</div>
</div>
{:else if tab === "reader"}
<div class="panel">
<div class="section">
<p class="section-title">Page Layout</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Default layout</span><span class="toggle-desc">How chapters open by default</span></div>
<div class="select-wrap" id="page-style">
<button class="select-btn" onclick={() => toggleSelect("page-style")}>
<span>{{ "single":"Single page","longstrip":"Long strip" }[store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle]}</span>
<svg class="select-caret" class:open={selectOpen === "page-style"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "page-style"}
<div class="select-menu">
{#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]}
<button class="select-option" class:active={(store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Reading direction</span><span class="toggle-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
<div class="select-wrap" id="reading-dir">
<button class="select-btn" onclick={() => toggleSelect("reading-dir")}>
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[store.settings.readingDirection]}</span>
<svg class="select-caret" class:open={selectOpen === "reading-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "reading-dir"}
<div class="select-menu">
{#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]}
<button class="select-option" class:active={store.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Page gap</span><span class="toggle-desc">Add spacing between pages in longstrip mode</span></div>
<button role="switch" aria-checked={store.settings.pageGap} aria-label="Page gap" class="toggle" class:on={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Fit &amp; Zoom</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Default fit mode</span><span class="toggle-desc">How pages are sized to fit the screen</span></div>
<div class="select-wrap" id="fit-mode">
<button class="select-btn" onclick={() => toggleSelect("fit-mode")}>
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[store.settings.fitMode ?? "width"]}</span>
<svg class="select-caret" class:open={selectOpen === "fit-mode"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "fit-mode"}
<div class="select-menu">
{#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]}
<button class="select-option" class:active={(store.settings.fitMode ?? "width") === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Max page width</span><span class="toggle-desc">Pixel cap for fit-width mode.</span></div>
<div class="step-controls">
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.max(200, (store.settings.maxPageWidth ?? 900) - 100) })}></button>
<span class="step-val">{store.settings.maxPageWidth ?? 900}px</span>
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.min(2400, (store.settings.maxPageWidth ?? 900) + 100) })}>+</button>
</div>
</div>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Behaviour</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-mark chapters read</span><span class="toggle-desc">Mark a chapter as read when you reach the last page</span></div>
<button role="switch" aria-checked={store.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="toggle" class:on={store.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !store.settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
</label>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span><span class="toggle-desc">Automatically open the next chapter at the end of a long strip</span></div>
<button role="switch" aria-checked={store.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="toggle" class:on={store.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
</label>
{#if !(store.settings.autoNextChapter ?? false)}
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Mark read when skipping to next chapter</span><span class="toggle-desc">Mark chapter as read when you tap next before finishing</span></div>
<button role="switch" aria-checked={store.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="toggle" class:on={store.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
</label>
{/if}
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Pages to preload</span><span class="toggle-desc">Images loaded ahead of the current page</span></div>
<div class="step-controls">
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, store.settings.preloadPages - 1) })} disabled={store.settings.preloadPages <= 0}></button>
<span class="step-val">{store.settings.preloadPages}</span>
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, store.settings.preloadPages + 1) })} disabled={store.settings.preloadPages >= 10}>+</button>
</div>
</div>
</div>
</div>
{:else if tab === "library"}
<div class="panel">
<div class="section">
<p class="section-title">Display</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Crop cover images</span><span class="toggle-desc">Fill grid cells — may crop cover edges</span></div>
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
</label>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Show NSFW sources</span><span class="toggle-desc">Display adult content sources in the sources list</span></div>
<button role="switch" aria-checked={store.settings.showNsfw} aria-label="Show NSFW sources" class="toggle" class:on={store.settings.showNsfw} onclick={() => updateSettings({ showNsfw: !store.settings.showNsfw })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Chapters</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Default sort direction</span></div>
<div class="select-wrap" id="sort-dir">
<button class="select-btn" onclick={() => toggleSelect("sort-dir")}>
<span>{{ "desc":"Newest first","asc":"Oldest first" }[store.settings.chapterSortDir]}</span>
<svg class="select-caret" class:open={selectOpen === "sort-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "sort-dir"}
<div class="select-menu">
{#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]}
<button class="select-option" class:active={store.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div class="section">
<p class="section-title">History</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Reading store.history</span><span class="toggle-desc">{store.history.length} entries stored</span></div>
<button class="danger-btn" onclick={clearHistory} disabled={store.history.length === 0}>Clear activity</button>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Full data cleanse</span>
<span class="toggle-desc">Removes store.history, stats, completed list, hero pins, and manga links</span>
</div>
<button class="danger-btn" onclick={wipeAllData}>Wipe all data</button>
</div>
</div>
</div>
{:else if tab === "performance"}
<div class="panel">
<div class="section">
<p class="section-title">Render Limit</p>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Items per page</span>
<span class="toggle-desc">Library and Search render this many items before showing a "Load more" button. Lower = faster scrolling on large libraries.</span>
</div>
<div class="step-controls">
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.max(12, (store.settings.renderLimit ?? 48) - 12) })} disabled={(store.settings.renderLimit ?? 48) <= 12}></button>
<span class="step-val">{store.settings.renderLimit ?? 48}</span>
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.min(200, (store.settings.renderLimit ?? 48) + 12) })} disabled={(store.settings.renderLimit ?? 48) >= 200}>+</button>
</div>
</div>
<p class="scale-hint">
{#each [12, 24, 48, 96, 200] as v}
<button class="scale-preset" class:active={(store.settings.renderLimit ?? 48) === v} onclick={() => updateSettings({ renderLimit: v })}>{v}</button>
{/each}
</p>
</div>
<div class="section">
<p class="section-title">Rendering</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">GPU acceleration</span><span class="toggle-desc">Promote reader and library to compositor layers</span></div>
<button role="switch" aria-checked={store.settings.gpuAcceleration} aria-label="GPU acceleration" class="toggle" class:on={store.settings.gpuAcceleration} onclick={() => updateSettings({ gpuAcceleration: !store.settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Idle / Splash Screen</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Animated card background</span><span class="toggle-desc">Show floating manga cards on splash and idle screens.</span></div>
<button role="switch" aria-checked={store.settings.splashCards ?? true} aria-label="Animated card background" class="toggle" class:on={store.settings.splashCards ?? true} onclick={() => updateSettings({ splashCards: !(store.settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Interface</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Compact sidebar</span><span class="toggle-desc">Reduce sidebar icon spacing</span></div>
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Session Cache</p>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Cache entries</span>
<span class="toggle-desc">In-memory request cache for this session (library, sources, genre pages). Cleared on restart.</span>
</div>
<div class="perf-stat-group">
<span class="perf-stat">{perfSnapshot?.cacheEntries ?? 0} entries</span>
<button class="kb-reset" onclick={refreshPerfMetrics} title="Refresh"></button>
</div>
</div>
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Oldest entry</span></div>
<span class="perf-stat">{fmtAge(perfSnapshot.oldestEntryMs)}</span>
</div>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Newest entry</span></div>
<span class="perf-stat">{fmtAge(perfSnapshot.newestEntryMs)}</span>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Cached keys</span>
<span class="toggle-desc">{perfSnapshot.cacheKeys.join(", ")}</span>
</div>
</div>
{/if}
</div>
</div>
{:else if tab === "keybinds"}
<div class="panel">
<div class="section">
<div class="kb-header">
<p class="section-title">Keyboard shortcuts</p>
<button class="reset-all-btn" onclick={resetKeybinds}>Reset all</button>
</div>
<p class="kb-hint">Click a key to rebind, then press the new combination.</p>
<div class="kb-list">
{#each Object.keys(KEYBIND_LABELS) as key}
{@const k = key as keyof Keybinds}
{@const isListening = listeningKey === k}
{@const isDefault = store.settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
<div class="kb-row">
<span class="kb-label">{KEYBIND_LABELS[k]}</span>
<div class="kb-right">
<button class="kb-bind" class:listening={isListening} onclick={() => startListen(k)}>
{isListening ? "Press key…" : store.settings.keybinds[k]}
</button>
<button class="kb-reset" onclick={() => updateSettings({ keybinds: { ...store.settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })} disabled={isDefault} title="Reset"></button>
</div>
</div>
{/each}
</div>
</div>
</div>
{:else if tab === "storage"}
<div class="panel">
<div class="section">
<p class="section-title">Disk Usage</p>
{#if storageLoading}<p class="storage-loading">Reading filesystem…</p>
{:else if storageError}<p class="storage-loading" style="color:var(--color-error)">{storageError}</p>
{:else if storageInfo}
{@const mangaBytes = storageInfo.manga_bytes}
{@const totalBytes = storageInfo.total_bytes}
{@const freeBytes = storageInfo.free_bytes}
{@const limitGb = store.settings.storageLimitGb ?? null}
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
{@const available = mangaBytes + freeBytes}
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
{@const pctUsed = cap > 0 ? Math.min(100, (mangaBytes / cap) * 100) : 0}
<div class="storage-bar-wrap">
<div class="storage-bar">
<div class="storage-bar-fill" class:critical={pctUsed > 90} class:warn={pctUsed > 75 && pctUsed <= 90} style="width:{pctUsed}%"></div>
</div>
<div class="storage-bar-labels">
<span class="storage-bar-used">{fmtBytes(mangaBytes)} used</span>
<span class="storage-bar-free">{fmtBytes(Math.max(0, cap - mangaBytes))} free</span>
</div>
</div>
<div class="storage-legend">
<div class="storage-legend-row"><span class="storage-dot storage-dot-manga"></span><span class="storage-legend-label">Downloaded manga</span><span class="storage-legend-val">{fmtBytes(mangaBytes)}</span></div>
<div class="storage-legend-row"><span class="storage-dot storage-dot-free"></span><span class="storage-legend-label">Drive free</span><span class="storage-legend-val">{fmtBytes(freeBytes)}</span></div>
<div class="storage-legend-row"><span class="storage-dot storage-dot-app"></span><span class="storage-legend-label">Drive total</span><span class="storage-legend-val">{fmtBytes(totalBytes)}</span></div>
</div>
<p class="storage-path-note">{storageInfo.path}</p>
{/if}
</div>
<div class="section">
<p class="section-title">Cache</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Image cache</span><span class="toggle-desc">Cached page images stored by the webview</span></div>
<button class="danger-btn" onclick={handleClearCache} disabled={clearing}>
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
</button>
</div>
</div>
<div class="section">
<p class="section-title">Storage Limit</p>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Limit download storage</span>
<span class="toggle-desc">
{store.settings.storageLimitGb === null
? "No limit — uses full drive capacity"
: `Warn when downloads exceed ${store.settings.storageLimitGb} GB`}
</span>
</div>
{#if store.settings.storageLimitGb === null}
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
onclick={() => updateSettings({ storageLimitGb: 10 })}>
Set limit
</button>
{:else}
<div class="step-controls">
<button class="step-btn"
onclick={() => updateSettings({ storageLimitGb: Math.max(1, (store.settings.storageLimitGb ?? 10) - 1) })}
disabled={(store.settings.storageLimitGb ?? 10) <= 1}></button>
<input
type="number" min="1" step="1"
class="storage-limit-input"
value={store.settings.storageLimitGb}
oninput={(e) => {
const n = parseFloat(e.currentTarget.value);
if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n });
}}
/>
<span class="storage-limit-unit">GB</span>
<button class="step-btn"
onclick={() => updateSettings({ storageLimitGb: (store.settings.storageLimitGb ?? 10) + 1 })}>+</button>
<button class="kb-reset" title="Remove limit"
onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
</div>
{/if}
</div>
</div>
</div>
{:else if tab === "folders"}
<div class="panel">
<div class="section">
<p class="section-title">Manage Folders</p>
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block">Assign manga to folders from the series detail page.</p>
<div class="folder-create-row">
<input class="text-input" placeholder="New folder name…" bind:value={newFolderName}
onkeydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" />
<button class="folder-create-btn" onclick={createFolder} disabled={!newFolderName.trim()}>
<Plus size={13} weight="bold" /> Create
</button>
</div>
{#if store.settings.folders.length === 0}
<p class="storage-loading">No folders yet. Create one above.</p>
{:else}
<div class="folder-list">
{#each store.settings.folders as folder}
<div class="folder-row">
{#if editingId === folder.id}
<input class="text-input" bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
onblur={commitEdit} style="flex:1;width:auto" use:focusInput />
<button class="kb-reset" onclick={commitEdit} title="Save"></button>
{:else}
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<span class="folder-row-name">{folder.name}</span>
<span class="folder-row-count">{folder.mangaIds.length} manga</span>
<button class="folder-tab-toggle" class:on={folder.showTab} onclick={() => toggleFolderTab(folder.id)}>
{folder.showTab ? "Tab on" : "Tab off"}
</button>
<button class="kb-reset" onclick={() => startEdit(folder.id, folder.name)} title="Rename"><Pencil size={12} weight="light" /></button>
<button class="kb-reset folder-delete" onclick={() => removeFolder(folder.id)} title="Delete"><Trash size={12} weight="light" /></button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{:else if tab === "about"}
<div class="panel">
<div class="section">
<p class="section-title">Moku</p>
<div class="about-block">
<p class="about-line">A manga reader frontend for Suwayomi / Tachidesk.</p>
<p class="about-line" style="color:var(--text-faint);margin-top:var(--sp-2)">Built with Tauri + Svelte.</p>
</div>
</div>
<div class="section">
<p class="section-title">Version</p>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Current version</span>
<span class="toggle-desc">v{appVersion}</span>
</div>
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
onclick={checkForUpdate} disabled={checkingUpdate}>
{checkingUpdate ? "Checking…" : "Check for updates"}
</button>
</div>
{#if updateError}
<p style="font-family:var(--font-ui);font-size:var(--text-xs);color:var(--color-error);padding:0 var(--sp-3) var(--sp-2)">{updateError}</p>
{:else if latestVersion !== null}
{#if latestVersion === appVersion}
<p style="font-family:var(--font-ui);font-size:var(--text-xs);color:#22c55e;padding:0 var(--sp-3) var(--sp-2);letter-spacing:var(--tracking-wide)">✓ You are on the latest version</p>
{:else}
<div style="padding:0 var(--sp-3) var(--sp-2);display:flex;flex-direction:column;gap:var(--sp-1)">
<p style="font-family:var(--font-ui);font-size:var(--text-xs);color:#fb923c;letter-spacing:var(--tracking-wide)">Update available — v{latestVersion}</p>
<a href="https://github.com/Youwes09/Moku/releases/latest" target="_blank"
style="font-family:var(--font-ui);font-size:var(--text-xs);color:var(--accent-fg);letter-spacing:var(--tracking-wide);text-decoration:none">
Download on GitHub →
</a>
</div>
{/if}
{/if}
</div>
<div class="section">
<p class="section-title">Links</p>
<div class="about-block">
<a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
</div>
</div>
</div>
{:else if tab === "devtools"}
<div class="panel">
<div class="section">
<p class="section-title">Splash Screen</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Preview idle screen</span><span class="toggle-desc">Show the idle splash — dismiss with any click or key</span></div>
<button class="danger-btn" onclick={triggerSplash}
style={splashTriggered ? "background:var(--accent-fg);color:var(--bg-base);border-color:var(--accent-fg);transition:all 0.15s ease" : ""}>
Show idle
</button>
</div>
</div>
<div class="section">
<p class="section-title">Build Info</p>
<div class="about-block">
<p class="about-line" style="font-family:monospace;font-size:11px;color:var(--text-faint)">Mode: {import.meta.env.MODE}</p>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<script module>
function focusInput(node: HTMLElement) { node.focus(); }
</script>
<style>
.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); }
.modal { width: min(720px, calc(100vw - 48px)); height: min(600px, 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.15s ease both; box-shadow: 0 24px 64px rgba(0,0,0,0.6); }
.sidebar { width: 168px; flex-shrink: 0; background: var(--bg-base); border-right: 1px solid var(--border-dim); padding: var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); overflow-y: auto; }
.modal-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 0 var(--sp-2) var(--sp-3); }
.nav { display: flex; flex-direction: column; gap: 1px; }
.nav-item { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-2); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
.nav-item:hover { background: var(--bg-raised); color: var(--text-secondary); }
.nav-item.active { background: var(--accent-muted); color: var(--accent-fg); }
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
.content-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-5) var(--sp-6) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.content-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.content-body { flex: 1; overflow-y: auto; }
.panel { display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-4) var(--sp-6); }
.section { display: flex; flex-direction: column; gap: 1px; border-bottom: 1px solid var(--border-dim); padding-bottom: var(--sp-4); margin-bottom: var(--sp-2); }
.section:last-child { border-bottom: none; }
.section-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-3) var(--sp-3) var(--sp-2); }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3); border-radius: var(--radius-md); cursor: default; transition: background var(--t-fast); }
.toggle-row:hover { background: var(--bg-raised); }
.toggle-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; margin-right: var(--sp-4); }
.toggle-label { font-size: var(--text-sm); color: var(--text-secondary); }
.toggle-desc { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.toggle { position: relative; width: 32px; height: 18px; border-radius: var(--radius-full); border: none; background: var(--bg-overlay); cursor: pointer; flex-shrink: 0; transition: background var(--t-base); border: 1px solid var(--border-strong); }
.toggle.on { background: var(--accent); border-color: var(--accent); }
.toggle-thumb { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
.toggle.on .toggle-thumb { transform: translateX(14px); background: var(--bg-void); }
.step-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3); border-radius: var(--radius-md); transition: background var(--t-fast); gap: var(--sp-3); }
.step-row:hover { background: var(--bg-raised); }
.step-controls { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
.step-btn { font-family: var(--font-ui); font-size: var(--text-sm); width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; }
.step-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
.step-btn:disabled { opacity: 0.3; cursor: default; }
.step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); min-width: 40px; text-align: center; }
.select-wrap { position: relative; flex-shrink: 0; }
.select-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; cursor: pointer; min-width: 130px; transition: border-color var(--t-base); }
.select-btn:hover { border-color: var(--border-strong); }
.select-caret { color: var(--text-faint); transition: transform var(--t-base); flex-shrink: 0; margin-left: auto; }
.select-caret.open { transform: rotate(180deg); }
.select-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 100%; 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.4); animation: scaleIn 0.1s ease both; transform-origin: top right; }
.select-option { display: block; width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); 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); }
.select-option:hover { background: var(--bg-overlay); color: var(--text-primary); }
.select-option.active { color: var(--accent-fg); background: var(--accent-muted); }
.text-input { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; width: 200px; transition: border-color var(--t-base); flex-shrink: 0; }
.text-input:focus { border-color: var(--border-strong); }
.danger-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--color-error); background: none; color: var(--color-error); cursor: pointer; flex-shrink: 0; transition: background var(--t-base); }
.danger-btn:hover:not(:disabled) { background: var(--color-error-bg); }
.danger-btn:disabled { opacity: 0.3; cursor: default; }
.scale-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
.scale-slider { flex: 1; }
.scale-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 40px; text-align: center; }
.scale-hint { padding: 0 var(--sp-3) var(--sp-2); display: flex; gap: var(--sp-1); flex-wrap: wrap; }
.scale-preset { font-family: var(--font-ui); font-size: var(--text-2xs); padding: 2px 7px; 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); }
.scale-preset:hover { color: var(--text-muted); border-color: var(--border-strong); }
.scale-preset.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
/* Theme */
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
.theme-card { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); overflow: hidden; cursor: pointer; text-align: left; transition: border-color var(--t-base), box-shadow var(--t-base); position: relative; }
.theme-card:hover { border-color: var(--border-strong); }
.theme-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.theme-preview { height: 70px; overflow: hidden; }
.theme-preview-bg { width: 100%; height: 100%; display: flex; }
.theme-preview-sidebar { width: 20%; height: 100%; flex-shrink: 0; }
.theme-preview-content { flex: 1; padding: 8px 6px; display: flex; flex-direction: column; gap: 5px; }
.theme-preview-accent { height: 6px; width: 50%; border-radius: 3px; }
.theme-preview-text { height: 4px; width: 100%; border-radius: 2px; }
.theme-card-info { padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; }
.theme-card-label { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.theme-card-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.theme-card-check { position: absolute; top: 6px; right: 6px; font-size: 10px; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 4px; }
/* Keybinds */
.kb-header { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-3) var(--sp-2); }
.reset-all-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.reset-all-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.kb-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-3) var(--sp-3); }
.kb-list { display: flex; flex-direction: column; gap: 1px; }
.kb-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); transition: background var(--t-fast); }
.kb-row:hover { background: var(--bg-raised); }
.kb-label { font-size: var(--text-sm); color: var(--text-secondary); flex: 1; }
.kb-right { display: flex; align-items: center; gap: var(--sp-2); }
.kb-bind { 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); background: var(--bg-raised); color: var(--text-secondary); cursor: pointer; min-width: 90px; text-align: center; transition: border-color var(--t-base), color var(--t-base); }
.kb-bind:hover { border-color: var(--border-strong); }
.kb-bind.listening { border-color: var(--accent); color: var(--accent-fg); background: var(--accent-muted); animation: pulse 1s ease infinite; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.6 } }
.kb-reset { font-size: var(--text-sm); color: var(--text-faint); padding: 3px 6px; border-radius: var(--radius-sm); border: 1px solid transparent; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.kb-reset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); background: var(--bg-overlay); }
.kb-reset:disabled { opacity: 0.3; cursor: default; }
/* Storage */
.storage-loading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-3); }
.storage-bar-wrap { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
.storage-bar { height: 6px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.storage-bar-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
.storage-bar-fill.warn { background: #d97706; }
.storage-bar-fill.critical { background: var(--color-error); }
.storage-bar-labels { display: flex; justify-content: space-between; }
.storage-bar-used, .storage-bar-free { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.storage-legend { display: flex; flex-direction: column; gap: var(--sp-1); padding: 0 var(--sp-3); }
.storage-legend-row { display: flex; align-items: center; gap: var(--sp-2); }
.storage-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.storage-dot-manga { background: var(--accent); }
.storage-dot-free { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
.storage-dot-app { background: var(--text-faint); }
.storage-legend-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; }
.storage-legend-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); }
.storage-path-note { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3) 0; word-break: break-all; }
/* Folders */
.folder-create-row { display: flex; gap: var(--sp-2); padding: 0 var(--sp-3) var(--sp-3); }
.folder-create-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: 5px 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; transition: filter var(--t-base); }
.folder-create-btn:hover:not(:disabled) { filter: brightness(1.1); }
.folder-create-btn:disabled { opacity: 0.4; cursor: default; }
.folder-list { display: flex; flex-direction: column; gap: 1px; padding: 0 var(--sp-3); }
.folder-row { display: flex; align-items: center; gap: var(--sp-2); padding: 8px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
.folder-row:hover { background: var(--bg-raised); }
.folder-row-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); }
.folder-row-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.folder-tab-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.folder-tab-toggle.on { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.folder-tab-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
.folder-delete:hover:not(:disabled) { color: var(--color-error) !important; }
/* About */
.about-block { padding: 0 var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); }
.about-line { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); }
/* Perf metrics */
.perf-stat-group { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.perf-stat { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
/* Storage limit */
.storage-limit-input {
width: 64px; text-align: center;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 3px 6px;
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
outline: none; transition: border-color var(--t-base);
-moz-appearance: textfield;
}
.storage-limit-input::-webkit-inner-spin-button,
.storage-limit-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.storage-limit-input:focus { border-color: var(--border-strong); }
.storage-limit-unit { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
</style>
-770
View File
@@ -1,770 +0,0 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash } from "@phosphor-icons/react";
import { invoke } from "@tauri-apps/api/core";
import { gql } from "../../lib/client";
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
import { useStore } from "../../store";
import type { Folder } from "../../store";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
import type { Settings, FitMode } from "../../store";
import s from "./Settings.module.css";
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about";
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
{ id: "performance", label: "Performance", icon: <Sliders size={14} weight="light" /> },
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
];
// ── Primitives ────────────────────────────────────────────────────────────────
function Toggle({ checked, onChange, label, description }: {
checked: boolean; onChange: (v: boolean) => void; label: string; description?: string;
}) {
return (
<label className={s.toggleRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>{label}</span>
{description && <span className={s.toggleDesc}>{description}</span>}
</div>
<button role="switch" aria-checked={checked}
className={[s.toggle, checked ? s.toggleOn : ""].join(" ")}
onClick={() => onChange(!checked)}>
<span className={s.toggleThumb} />
</button>
</label>
);
}
function Stepper({ value, onChange, min, max, step = 1, label, description }: {
value: number; onChange: (v: number) => void;
min: number; max: number; step?: number; label: string; description?: string;
}) {
return (
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>{label}</span>
{description && <span className={s.toggleDesc}>{description}</span>}
</div>
<div className={s.stepControls}>
<button className={s.stepBtn} onClick={() => onChange(Math.max(min, value - step))} disabled={value <= min}></button>
<span className={s.stepVal}>{value}</span>
<button className={s.stepBtn} onClick={() => onChange(Math.min(max, value + step))} disabled={value >= max}>+</button>
</div>
</div>
);
}
function SelectRow({ value, options, onChange, label, description }: {
value: string;
options: { value: string; label: string }[];
onChange: (v: string) => void;
label: string;
description?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const selected = options.find((o) => o.value === value);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>{label}</span>
{description && <span className={s.toggleDesc}>{description}</span>}
</div>
<div className={s.selectWrap} ref={ref}>
<button className={s.selectBtn} onClick={() => setOpen((o) => !o)}>
<span>{selected?.label ?? value}</span>
<svg className={[s.selectCaret, open ? s.selectCaretOpen : ""].join(" ")} width="10" height="6" viewBox="0 0 10 6" fill="none">
<path d="M0 0l5 6 5-6" fill="currentColor" />
</svg>
</button>
{open && (
<div className={s.selectMenu}>
{options.map((o) => (
<button
key={o.value}
className={[s.selectOption, o.value === value ? s.selectOptionActive : ""].join(" ")}
onClick={() => { onChange(o.value); setOpen(false); }}
>
{o.label}
</button>
))}
</div>
)}
</div>
</div>
);
}
function TextRow({ value, onChange, label, description, placeholder }: {
value: string; onChange: (v: string) => void;
label: string; description?: string; placeholder?: string;
}) {
return (
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>{label}</span>
{description && <span className={s.toggleDesc}>{description}</span>}
</div>
<input className={s.textInput} value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} spellCheck={false} />
</div>
);
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Interface Scale</p>
<div className={s.scaleRow}>
<input type="range" min={70} max={150} step={5}
value={settings.uiScale}
onChange={(e) => update({ uiScale: Number(e.target.value) })}
className={s.scaleSlider} />
<span className={s.scaleVal}>{settings.uiScale}%</span>
<button className={s.stepBtn}
onClick={() => update({ uiScale: 100 })}
disabled={settings.uiScale === 100} title="Reset"></button>
</div>
<p className={s.scaleHint}>
{[70, 80, 90, 100, 110, 125, 150].map((v) => (
<button key={v}
className={[s.scalePreset, settings.uiScale === v ? s.scalePresetActive : ""].join(" ")}
onClick={() => update({ uiScale: v })}>{v}%</button>
))}
</p>
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Server</p>
<TextRow label="Server URL" description="Base URL of your Suwayomi instance"
value={settings.serverUrl ?? "http://localhost:4567"}
onChange={(v) => update({ serverUrl: v })}
placeholder="http://localhost:4567" />
<TextRow label="Server binary" description="Path or command to launch tachidesk-server"
value={settings.serverBinary}
onChange={(v) => update({ serverBinary: v })}
placeholder="tachidesk-server" />
<Toggle label="Auto-start server"
description="Launch tachidesk-server when Moku opens"
checked={settings.autoStartServer}
onChange={(v) => update({ autoStartServer: v })} />
</div>
</div>
);
}
function ReaderTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Page Layout</p>
<SelectRow label="Default layout"
description="How chapters open by default"
value={settings.pageStyle === "double" ? "single" : settings.pageStyle}
options={[
{ value: "single", label: "Single page" },
{ value: "longstrip", label: "Long strip" },
]}
onChange={(v) => update({ pageStyle: v as Settings["pageStyle"] })} />
<SelectRow label="Reading direction"
description="Left-to-right for most manga, right-to-left for Japanese"
value={settings.readingDirection}
options={[
{ value: "ltr", label: "Left to right" },
{ value: "rtl", label: "Right to left" },
]}
onChange={(v) => update({ readingDirection: v as Settings["readingDirection"] })} />
<Toggle label="Page gap"
description="Add spacing between pages in longstrip mode"
checked={settings.pageGap}
onChange={(v) => update({ pageGap: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Fit &amp; Zoom</p>
<SelectRow label="Default fit mode"
description="How pages are sized to fit the screen"
value={settings.fitMode ?? "width"}
options={[
{ value: "width", label: "Fit width" },
{ value: "height", label: "Fit height" },
{ value: "screen", label: "Fit screen" },
{ value: "original", label: "Original (1:1)" },
]}
onChange={(v) => update({ fitMode: v as FitMode })} />
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>Max page width</span>
<span className={s.toggleDesc}>Pixel cap for fit-width mode. Ctrl+scroll in reader to adjust live.</span>
</div>
<div className={s.stepControls}>
<button className={s.stepBtn} onClick={() => update({ maxPageWidth: Math.max(200, (settings.maxPageWidth ?? 900) - 100) })}></button>
<span className={s.stepVal}>{settings.maxPageWidth ?? 900}px</span>
<button className={s.stepBtn} onClick={() => update({ maxPageWidth: Math.min(2400, (settings.maxPageWidth ?? 900) + 100) })}>+</button>
</div>
</div>
<Toggle label="Optimize contrast"
description="Use webkit-optimize-contrast rendering (sharper on low-DPI)"
checked={settings.optimizeContrast}
onChange={(v) => update({ optimizeContrast: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Behaviour</p>
<Toggle label="Auto-mark chapters read"
description="Mark a chapter as read when you reach the last page"
checked={settings.autoMarkRead}
onChange={(v) => update({ autoMarkRead: v })} />
<Toggle label="Auto-advance chapters"
description="Automatically open the next chapter at the end of a long strip"
checked={settings.autoNextChapter ?? false}
onChange={(v) => update({ autoNextChapter: v })} />
<Stepper label="Pages to preload"
description="Images loaded ahead of the current page"
value={settings.preloadPages} min={0} max={10}
onChange={(v) => update({ preloadPages: v })} />
</div>
</div>
);
}
function LibraryTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
const clearHistory = useStore((s) => s.clearHistory);
const historyLen = useStore((s) => s.history.length);
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Display</p>
<Toggle label="Crop cover images"
description="Fill grid cells — may crop cover edges"
checked={settings.libraryCropCovers}
onChange={(v) => update({ libraryCropCovers: v })} />
<Toggle label="Show NSFW sources"
description="Display adult content sources in the sources list"
checked={settings.showNsfw}
onChange={(v) => update({ showNsfw: v })} />
<Stepper label="Initial cards to display"
description="Cards shown before 'Show more' appears"
value={settings.libraryPageSize} min={12} max={200} step={12}
onChange={(v) => update({ libraryPageSize: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Chapters</p>
<SelectRow label="Default sort direction"
value={settings.chapterSortDir}
options={[
{ value: "desc", label: "Newest first" },
{ value: "asc", label: "Oldest first" },
]}
onChange={(v) => update({ chapterSortDir: v as Settings["chapterSortDir"] })} />
<Stepper label="Chapters per page"
description="Chapter list pagination size"
value={settings.chapterPageSize} min={10} max={100} step={5}
onChange={(v) => update({ chapterPageSize: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Extensions</p>
<SelectRow label="Preferred language"
description="Language variant shown first when an extension has multiple"
value={settings.preferredExtensionLang ?? "en"}
options={[
{ value: "en", label: "English" },
{ value: "es", label: "Spanish" },
{ value: "fr", label: "French" },
{ value: "de", label: "German" },
{ value: "pt-br", label: "Portuguese (BR)" },
{ value: "it", label: "Italian" },
{ value: "ru", label: "Russian" },
{ value: "ar", label: "Arabic" },
{ value: "tr", label: "Turkish" },
{ value: "zh", label: "Chinese (Simplified)" },
{ value: "zh-hant", label: "Chinese (Traditional)" },
{ value: "ko", label: "Korean" },
{ value: "ja", label: "Japanese" },
{ value: "id", label: "Indonesian" },
{ value: "vi", label: "Vietnamese" },
{ value: "th", label: "Thai" },
{ value: "pl", label: "Polish" },
{ value: "nl", label: "Dutch" },
]}
onChange={(v) => update({ preferredExtensionLang: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>History</p>
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>Reading history</span>
<span className={s.toggleDesc}>{historyLen} entries stored</span>
</div>
<button className={s.dangerBtn} onClick={clearHistory} disabled={historyLen === 0}>
Clear history
</button>
</div>
</div>
</div>
);
}
function PerformanceTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Rendering</p>
<Toggle label="GPU acceleration"
description="Promote reader and library to compositor layers (recommended)"
checked={settings.gpuAcceleration}
onChange={(v) => update({ gpuAcceleration: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Interface</p>
<Toggle label="Compact sidebar"
description="Reduce sidebar icon spacing"
checked={settings.compactSidebar}
onChange={(v) => update({ compactSidebar: v })} />
</div>
</div>
);
}
function KeybindsTab({ settings, update, reset }: {
settings: Settings; update: (p: Partial<Settings>) => void; reset: () => void;
}) {
const [listening, setListening] = useState<keyof Keybinds | null>(null);
useEffect(() => {
if (!listening) return;
function onKey(e: KeyboardEvent) {
e.preventDefault(); e.stopPropagation();
const bind = eventToKeybind(e);
if (!bind) return;
update({ keybinds: { ...settings.keybinds, [listening!]: bind } });
setListening(null);
}
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [listening, settings.keybinds]);
return (
<div className={s.panel}>
<div className={s.section}>
<div className={s.kbHeader}>
<p className={s.sectionTitle}>Keyboard shortcuts</p>
<button className={s.resetAllBtn} onClick={reset}>Reset all</button>
</div>
<p className={s.kbHint}>Click a key to rebind, then press the new combination.</p>
<div className={s.kbList}>
{(Object.keys(KEYBIND_LABELS) as (keyof Keybinds)[]).map((key) => {
const isListening = listening === key;
const isDefault = settings.keybinds[key] === DEFAULT_KEYBINDS[key];
return (
<div key={key} className={s.kbRow}>
<span className={s.kbLabel}>{KEYBIND_LABELS[key]}</span>
<div className={s.kbRight}>
<button
className={[s.kbBind, isListening ? s.kbBindListening : ""].join(" ")}
onClick={() => setListening(isListening ? null : key)}>
{isListening ? "Press key…" : settings.keybinds[key]}
</button>
<button className={s.kbReset}
onClick={() => update({ keybinds: { ...settings.keybinds, [key]: DEFAULT_KEYBINDS[key] } })}
disabled={isDefault} title="Reset"></button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Storage helpers ───────────────────────────────────────────────────────────
function fmtBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
}
interface StorageInfo {
manga_bytes: number;
total_bytes: number;
free_bytes: number;
path: string;
}
function StorageBar({ used, limit, total }: { used: number; limit: number | null; total: number }) {
const cap = limit ?? total;
const pctUsed = cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
const critical = pctUsed > 90;
const warning = pctUsed > 75;
return (
<div className={s.storageBarWrap}>
<div className={s.storageBar}>
<div
className={[s.storageBarFill, critical ? s.storageBarCritical : warning ? s.storageBarWarn : ""].join(" ")}
style={{ width: `${pctUsed}%` }}
/>
</div>
<div className={s.storageBarLabels}>
<span className={s.storageBarUsed}>{fmtBytes(used)} used</span>
<span className={s.storageBarFree}>{fmtBytes(Math.max(0, cap - used))} free</span>
</div>
{limit !== null && total > 0 && (
<p className={s.storageBarNote}>Limit {fmtBytes(limit)} of {fmtBytes(total)} total</p>
)}
</div>
);
}
function StorageTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
const [info, setInfo] = useState<StorageInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [cleared, setCleared] = useState(false);
const limitGb = settings.storageLimitGb ?? null;
const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null;
async function fetchInfo() {
setLoading(true);
setError(null);
try {
const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH);
const result = await invoke<StorageInfo>("get_storage_info", {
downloadsPath: pathData.settings.downloadsPath,
});
setInfo(result);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}
useEffect(() => { fetchInfo(); }, []);
function handleClearCache() {
setClearing(true);
caches.keys()
.then((names) => Promise.all(names.map((n) => caches.delete(n))))
.catch(() => {})
.finally(() => {
setClearing(false);
setCleared(true);
setTimeout(() => setCleared(false), 2500);
fetchInfo();
});
}
const mangaBytes = info?.manga_bytes ?? 0;
const totalBytes = info?.total_bytes ?? 0;
const freeBytes = info?.free_bytes ?? 0;
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Disk Usage</p>
{loading && <p className={s.storageLoading}>Reading filesystem</p>}
{error && <p className={s.storageLoading} style={{ color: "var(--color-error)" }}>{error}</p>}
{!loading && !error && info && (
<>
<StorageBar used={mangaBytes} limit={limitBytes} total={totalBytes} />
<div className={s.storageLegend}>
<div className={s.storageLegendRow}>
<span className={[s.storageDot, s.storageDotManga].join(" ")} />
<span className={s.storageLegendLabel}>Downloaded manga</span>
<span className={s.storageLegendVal}>{fmtBytes(mangaBytes)}</span>
</div>
<div className={s.storageLegendRow}>
<span className={[s.storageDot, s.storageDotFree].join(" ")} />
<span className={s.storageLegendLabel}>Drive free</span>
<span className={s.storageLegendVal}>{fmtBytes(freeBytes)}</span>
</div>
<div className={s.storageLegendRow}>
<span className={[s.storageDot, s.storageDotApp].join(" ")} />
<span className={s.storageLegendLabel}>Drive total</span>
<span className={s.storageLegendVal}>{fmtBytes(totalBytes)}</span>
</div>
</div>
<p className={s.storagePathNote}>{info.path}</p>
</>
)}
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Storage Limit</p>
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>Limit download storage</span>
<span className={s.toggleDesc}>
{limitGb === null
? "No limit — uses full drive capacity"
: `Warn when downloads exceed ${limitGb} GB`}
</span>
</div>
{limitGb === null ? (
<button className={s.setLimitBtn} onClick={() => update({ storageLimitGb: 10 })}>
Set limit
</button>
) : (
<div className={s.stepControls}>
<button className={s.stepBtn}
onClick={() => update({ storageLimitGb: Math.max(1, limitGb - 1) })}
disabled={limitGb <= 1}>&#8722;</button>
<span className={s.stepVal}>{limitGb} GB</span>
<button className={s.stepBtn}
onClick={() => update({ storageLimitGb: limitGb + 1 })}>+</button>
<button className={s.kbReset} onClick={() => update({ storageLimitGb: null })} title="Remove limit"></button>
</div>
)}
</div>
{totalBytes > 0 && limitGb !== null && limitBytes !== null && limitBytes > freeBytes && (
<p className={s.storageLimitHint}>Limit exceeds available free space ({fmtBytes(freeBytes)})</p>
)}
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Cache</p>
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>Image cache</span>
<span className={s.toggleDesc}>Cached page images stored by the webview</span>
</div>
<button className={s.dangerBtn} onClick={handleClearCache} disabled={clearing}>
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
</button>
</div>
</div>
</div>
);
}
// ── Folders tab ───────────────────────────────────────────────────────────────
function FoldersTab() {
const folders = useStore((s) => s.settings.folders);
const addFolder = useStore((s) => s.addFolder);
const removeFolder = useStore((s) => s.removeFolder);
const renameFolder = useStore((s) => s.renameFolder);
const toggleFolderTab = useStore((s) => s.toggleFolderTab);
const [newName, setNewName] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
function handleCreate() {
const name = newName.trim();
if (!name) return;
addFolder(name);
setNewName("");
}
function startEdit(folder: Folder) {
setEditingId(folder.id);
setEditingName(folder.name);
}
function commitEdit() {
if (editingId && editingName.trim()) {
renameFolder(editingId, editingName.trim());
}
setEditingId(null);
setEditingName("");
}
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Manage Folders</p>
<p className={s.toggleDesc} style={{ padding: "0 var(--sp-3) var(--sp-3)", display: "block" }}>
Assign manga to folders from the series detail page. Toggle the tab icon to show a folder as a filter tab in the library.
</p>
{/* Create new folder */}
<div className={s.folderCreateRow}>
<input
className={s.textInput}
placeholder="New folder name…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
style={{ flex: 1, width: "auto" }}
/>
<button
className={s.folderCreateBtn}
onClick={handleCreate}
disabled={!newName.trim()}
>
<Plus size={13} weight="bold" />
Create
</button>
</div>
{/* Folder list */}
{folders.length === 0 ? (
<p className={s.storageLoading}>No folders yet. Create one above.</p>
) : (
<div className={s.folderList}>
{folders.map((folder) => (
<div key={folder.id} className={s.folderRow}>
{editingId === folder.id ? (
<>
<input
autoFocus
className={s.textInput}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") { setEditingId(null); }
}}
onBlur={commitEdit}
style={{ flex: 1, width: "auto" }}
/>
<button className={s.kbReset} onClick={commitEdit} title="Save"></button>
</>
) : (
<>
<FolderSimple size={14} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
<span className={s.folderRowName}>{folder.name}</span>
<span className={s.folderRowCount}>{folder.mangaIds.length} manga</span>
{/* Show as tab toggle */}
<button
className={[s.folderTabToggle, folder.showTab ? s.folderTabToggleOn : ""].join(" ")}
onClick={() => toggleFolderTab(folder.id)}
title={folder.showTab ? "Shown as library tab — click to hide" : "Click to show as library tab"}
>
{folder.showTab ? "Tab on" : "Tab off"}
</button>
<button
className={s.kbReset}
onClick={() => startEdit(folder)}
title="Rename"
>
<Pencil size={12} weight="light" />
</button>
<button
className={[s.kbReset, s.folderDeleteBtn].join(" ")}
onClick={() => removeFolder(folder.id)}
title="Delete folder"
>
<Trash size={12} weight="light" />
</button>
</>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
function AboutTab() {
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Moku</p>
<div className={s.aboutBlock}>
<p className={s.aboutLine}>A manga reader frontend for Suwayomi / Tachidesk.</p>
<p className={s.aboutLine} style={{ color: "var(--text-faint)", marginTop: "var(--sp-2)" }}>
Built with Tauri + React. Connects to tachidesk-server.
</p>
</div>
</div>
</div>
);
}
// ── Modal ─────────────────────────────────────────────────────────────────────
export default function SettingsModal() {
const [tab, setTab] = useState<Tab>("general");
const closeSettings = useStore((s) => s.closeSettings);
const settings = useStore((s) => s.settings);
const updateSettings = useStore((s) => s.updateSettings);
const resetKeybinds = useStore((s) => s.resetKeybinds);
const backdropRef = useRef<HTMLDivElement>(null);
const contentBodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
contentBodyRef.current?.scrollTo({ top: 0 });
}, [tab]);
const handleBackdrop = useCallback(
(e: React.MouseEvent) => { if (e.target === backdropRef.current) closeSettings(); },
[closeSettings]
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeSettings(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [closeSettings]);
return (
<div className={s.backdrop} ref={backdropRef} onClick={handleBackdrop}>
<div className={s.modal} role="dialog" aria-label="Settings">
<div className={s.sidebar}>
<p className={s.modalTitle}>Settings</p>
<nav className={s.nav}>
{TABS.map((t) => (
<button key={t.id}
className={[s.navItem, tab === t.id ? s.navActive : ""].join(" ")}
onClick={() => setTab(t.id)}>
{t.icon}
<span>{t.label}</span>
</button>
))}
</nav>
</div>
<div className={s.content}>
<div className={s.contentHeader}>
<p className={s.contentTitle}>{TABS.find((t) => t.id === tab)?.label}</p>
<button className={s.closeBtn} onClick={closeSettings}><X size={15} weight="light" /></button>
</div>
<div className={s.contentBody} ref={contentBodyRef}>
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
{tab === "keybinds" && <KeybindsTab settings={settings} update={updateSettings} reset={resetKeybinds} />}
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
{tab === "folders" && <FoldersTab />}
{tab === "about" && <AboutTab />}
</div>
</div>
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
export interface MenuItem {
label: string;
icon?: any;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
separator?: never;
}
export interface MenuSeparator { separator: true }
export type MenuEntry = MenuItem | MenuSeparator;
interface Props {
x: number;
y: number;
items: MenuEntry[];
onClose: () => void;
}
let { x, y, items, onClose }: Props = $props();
let focused = $state(-1);
let el = $state<HTMLDivElement | undefined>(undefined);
const actionable = $derived(
items
.map((_, i) => i)
.filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled)
);
$effect(() => { if (actionable.length && focused === -1) focused = actionable[0]; });
const pos = $derived.by(() => {
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
const menuW = 200, menuH = items.length * 34;
const vw = window.innerWidth / zoom, vh = window.innerHeight / zoom;
const sx = x / zoom, sy = y / zoom;
return {
left: Math.max(4, sx + menuW > vw ? sx - menuW : sx),
top: Math.max(4, sy + menuH > vh ? sy - menuH : sy),
};
});
function onMouseDown(e: MouseEvent) {
if (el && !el.contains(e.target as Node)) onClose();
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
if (e.key === "ArrowDown") {
e.preventDefault();
const cur = actionable.indexOf(focused);
focused = actionable[(cur + 1) % actionable.length] ?? actionable[0];
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
const cur = actionable.indexOf(focused);
focused = actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
return;
}
if (e.key === "Enter" && focused >= 0) {
e.preventDefault();
const item = items[focused] as MenuItem;
if (item && !item.disabled) { item.onClick(); onClose(); }
}
}
$effect(() => {
document.addEventListener("mousedown", onMouseDown, true);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("keydown", onKey, true);
};
});
</script>
<div bind:this={el} class="menu" role="menu" tabindex="-1" style="left:{pos.left}px;top:{pos.top}px"
oncontextmenu={(e) => e.preventDefault()}>
{#each items as item, i}
{#if "separator" in item}
<div class="sep"></div>
{:else}
{@const mi = item as MenuItem}
<button
class="item"
class:danger={mi.danger}
class:disabled={mi.disabled}
class:focused={focused === i}
disabled={mi.disabled}
onclick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
onmouseenter={() => { if (!mi.disabled) focused = i; }}
onmouseleave={() => focused = -1}
>
<span class="icon" class:icon-danger={mi.danger}>
{#if mi.icon}<mi.icon size={13} weight="light" />{/if}
</span>
<span class="label">{mi.label}</span>
</button>
{/if}
{/each}
</div>
<style>
.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);
background: none; border: none; outline: none;
}
.item:hover:not(.disabled), .item.focused:not(.disabled) { background: var(--bg-overlay); color: var(--text-primary); }
.item.danger { color: var(--color-error); }
.item.danger:hover:not(.disabled), .item.danger.focused:not(.disabled) { background: var(--color-error-bg); }
.item.disabled { opacity: 0.3; cursor: default; pointer-events: none; }
.icon {
display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; flex-shrink: 0;
color: var(--text-faint); border-radius: var(--radius-sm);
}
.icon-danger { color: var(--color-error); opacity: 0.7; }
.label { flex: 1; line-height: 1.3; }
.sep { height: 1px; background: var(--border-dim); margin: 3px var(--sp-1); }
</style>
+509
View File
@@ -0,0 +1,509 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { GET_ALL_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
import type { Manga, Chapter } from "../../lib/types";
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingDetail = $state(false);
let loadingChapters = $state(false);
let togglingLib = $state(false);
let descExpanded = $state(false);
let folderOpen = $state(false);
let newFolderName = $state("");
let creatingFolder = $state(false);
let queueingAll = $state(false);
let fetchError: string|null = $state(null);
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
let linkPickerOpen = $state(false);
let linkSearch = $state("");
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList = $state(false);
const linkedIds = $derived(store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : []);
const linkPickerResults = $derived.by(() => {
const others = allMangaForLink.filter((m) => m.id !== store.previewManga?.id);
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
async function openLinkPicker() {
linkPickerOpen = true; linkSearch = "";
if (allMangaForLink.length) return;
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then(d => { allMangaForLink = d.mangas.nodes; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
function handleLink(other: Manga) {
if (!store.previewManga) return;
if (linkedIds.includes(other.id)) unlinkManga(store.previewManga.id, other.id);
else linkManga(store.previewManga.id, other.id);
}
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
function close() {
detailAbort?.abort(); chapterAbort?.abort();
setPreviewManga(null);
manga = null; chapters = []; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
}
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
const displayManga = $derived(manga ?? store.previewManga);
const totalCount = $derived(chapters.length);
const readCount = $derived(chapters.filter((c) => c.isRead).length);
const unreadCount = $derived(totalCount - readCount);
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
const inLibrary = $derived(manga?.inLibrary ?? store.previewManga?.inLibrary ?? false);
const scanlators = $derived([...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))]);
const uploadDates = $derived(chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d)));
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(store.previewManga ? store.settings.folders.filter((f) => f.mangaIds.includes(store.previewManga!.id)) : []);
const continueChapter = $derived.by(() => {
if (!chapters.length) return null;
const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
const firstUnread = chapters.find((c) => !c.isRead);
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
return { ch: chapters[0], label: "Read again" };
});
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort();
const dCtrl = new AbortController(), cCtrl = new AbortController();
detailAbort = dCtrl; chapterAbort = cCtrl;
manga = store.previewManga as Manga;
chapters = []; descExpanded = false; fetchError = null;
loadingDetail = true; loadingChapters = true;
(async (): Promise<Manga> => {
const key = CACHE_KEYS.MANGA(id);
if (cache.has(key)) return cache.get(key, () => Promise.resolve(store.previewManga as Manga)) as Promise<Manga>;
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;
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;
if (!cache.has(CACHE_KEYS.MANGA(id))) cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
manga = fullManga; loadingDetail = false;
}).catch((e) => {
if (e?.name === "AbortError") return;
manga = store.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
loadingDetail = false;
});
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 (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; }
}
if (!cCtrl.signal.aborted) {
chapters = nodes;
if (nodes.length > 0) checkAndMarkCompleted(id, nodes);
}
})
.catch(() => {})
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
}
async function toggleLibrary() {
if (!manga) return;
togglingLib = true;
const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
manga = { ...manga, inLibrary: next };
cache.clear(CACHE_KEYS.MANGA(manga.id));
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(manga!));
cache.clear(CACHE_KEYS.LIBRARY);
togglingLib = 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;
queueingAll = true;
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
queueingAll = false;
}
function openSeriesDetail() {
if (!displayManga) return;
setActiveManga(displayManga);
setNavPage("library");
close();
}
function handleFolderCreate() {
const name = newFolderName.trim();
if (!name || !store.previewManga) return;
const id = addFolder(name);
assignMangaToFolder(id, store.previewManga.id);
newFolderName = ""; creatingFolder = false;
}
function handleFolderOutside(e: MouseEvent) {
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; }
}
$effect(() => {
if (folderOpen) {
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
return () => document.removeEventListener("mousedown", handleFolderOutside);
}
});
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
onMount(() => window.addEventListener("keydown", onKey));
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); });
</script>
{#if store.previewManga}
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="modal" role="dialog" aria-label="Manga preview">
<div class="cover-col">
<div class="cover-wrap">
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
{#if loadingDetail}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if}
</div>
<div class="cover-actions">
<button class="action-btn" class:active={inLibrary} onclick={toggleLibrary} disabled={togglingLib || loadingDetail}>
<span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
</button>
<button class="action-btn" onclick={openSeriesDetail}>
<span class="action-icon"><Books size={13} weight="light" /></span>
<span class="action-label">Series Detail</span>
</button>
<div class="folder-wrap" bind:this={folderRef}>
<button class="action-btn" class:active={assignedFolders.length > 0} onclick={() => folderOpen = !folderOpen}>
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
<span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span>
</button>
{#if folderOpen}
<div class="folder-menu">
{#if store.settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
{#each store.settings.folders as f}
{@const isIn = store.previewManga ? f.mangaIds.includes(store.previewManga.id) : false}
<button class="folder-item" class:folder-item-on={isIn}
onclick={() => store.previewManga && (isIn ? removeMangaFromFolder(f.id, store.previewManga.id) : assignMangaToFolder(f.id, store.previewManga.id))}>
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
</button>
{/each}
<div class="folder-divider"></div>
{#if creatingFolder}
<div class="folder-create-row">
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }}
use:focusAction />
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
</div>
{:else}
<button class="folder-new" onclick={() => creatingFolder = true}>+ New folder</button>
{/if}
</div>
{/if}
</div>
<button class="action-btn" class:active={linkedIds.length > 0} onclick={openLinkPicker}>
<span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span>
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
</button>
</div>
</div>
<div class="content">
<div class="content-header">
<div class="title-block">
<h2 class="title">{displayManga?.title}</h2>
{#if loadingDetail}
<div class="sk-byline"></div>
{:else if displayManga?.author || displayManga?.artist}
<p class="byline">{[displayManga?.author, displayManga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
{/if}
</div>
<button class="close-btn" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="content-body">
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
{#if loadingDetail}
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
{:else}
<div class="badges">
{#if statusLabel}<span class="badge" class:badge-green={displayManga?.status === "ONGOING"}>{statusLabel}</span>{/if}
{#if displayManga?.source}<span class="badge">{displayManga.source.displayName}</span>{/if}
{#if inLibrary}<span class="badge badge-accent">In Library</span>{/if}
{#if !loadingChapters && unreadCount > 0}<span class="badge badge-unread">{unreadCount} unread</span>{/if}
{#if !loadingChapters && bookmarkCount > 0}<span class="badge">{bookmarkCount} bookmarked</span>{/if}
</div>
{/if}
<div class="chapter-box">
{#if loadingChapters}
<div class="chapter-loading">
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="chapter-loading-label">Loading chapters…</span>
</div>
{:else if totalCount > 0}
<div class="chapter-meta">
<span class="chapter-label">
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
</span>
{#if unreadCount > 0}
<button class="dl-all-btn" onclick={downloadAll} disabled={queueingAll}>
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
{queueingAll ? "Queuing…" : "Download unread"}
</button>
{/if}
</div>
{#if readCount > 0}
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
{/if}
{#if continueChapter}
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}>
<Play size={12} weight="fill" />{continueChapter.label}
</button>
{/if}
{:else if !loadingDetail}
<span class="chapter-label" style="color:var(--text-faint)">No chapters in local library</span>
{/if}
</div>
{#if loadingDetail}
<div class="sk-desc">
<div class="sk-line" style="width:100%"></div>
<div class="sk-line" style="width:88%"></div>
<div class="sk-line" style="width:70%"></div>
</div>
{:else if displayManga?.description}
<div class="desc-block">
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
{#if displayManga.description.length > 220}
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>
{descExpanded ? "Show less" : "Show more"}
<CaretDown size={10} weight="light" style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease" />
</button>
{/if}
</div>
{/if}
{#if !loadingDetail && displayManga?.genre?.length}
<div class="genres">
{#each displayManga.genre as g}
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("explore"); close(); }}>{g}</button>
{/each}
</div>
{/if}
{#if !loadingDetail}
<div class="meta-table">
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if}
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if}
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if}
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if}
{#if !loadingChapters && firstUpload && lastUpload}
<div class="meta-row">
<span class="meta-key">Published</span>
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} ${formatDate(lastUpload)}`}</span>
</div>
{/if}
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if}
{#if displayManga?.realUrl}<div class="meta-row"><span class="meta-key">Link</span><a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a></div>{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
{#if linkPickerOpen}
<div class="link-backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
<div class="link-modal">
<div class="link-header">
<span class="link-title">Link as same series</span>
<button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
</div>
<p class="link-hint">
Mark two manga as the same series so duplicates are merged in search and discover.
Click a linked entry again to unlink.
</p>
<div class="link-search-wrap">
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusAction />
</div>
<div class="link-list">
{#if loadingLinkList}
<p class="link-empty">Loading…</p>
{:else if linkPickerResults.length === 0}
<p class="link-empty">No results</p>
{:else}
{#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
<div class="link-info">
<span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
</div>
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if}
{/if}
<script module>
function focusAction(node: HTMLElement) { node.focus(); }
</script>
<style>
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
.cover-wrap { position: relative; width: 100%; }
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
.action-btn:disabled { opacity: 0.4; cursor: default; }
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.folder-wrap { position: relative; width: 100%; }
.folder-menu { 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; }
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
.folder-item { 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); }
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.folder-item.folder-item-on { color: var(--accent-fg); }
.folder-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
.folder-input { 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; }
.folder-input:focus { border-color: var(--border-focus); }
.folder-ok { 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); }
.folder-ok:disabled { opacity: 0.4; cursor: default; }
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
.folder-new { 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); }
.folder-new:hover { color: var(--accent-fg); }
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
.content-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); }
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.content-body { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); scrollbar-width: none; }
.content-body::-webkit-scrollbar { display: none; }
.error-banner { font-family: var(--font-ui); font-size: var(--text-xs); color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm); padding: 6px var(--sp-3); }
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.sk-desc { display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; }
.sk-line { height: 13px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.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); }
.badge-green { background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.3); color: #22c55e; }
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.badge-unread { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.3); color: #f59e0b; }
.chapter-box { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
.chapter-loading { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-loading-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.dl-all-btn { 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); }
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.read-btn { 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); }
.read-btn:hover { filter: brightness(1.1); }
.desc-block { 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; }
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
.desc-toggle { 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); }
.desc-toggle:hover { color: var(--accent-fg); }
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
.meta-link:hover { opacity: 0.75; }
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; 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; }
.link-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; }
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.link-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0; }
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.link-search { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
.link-search:focus { border-color: var(--border-strong); }
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.link-list::-webkit-scrollbar { display: none; }
.link-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; letter-spacing: var(--tracking-wide); }
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; }
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { store, addFolder, assignMangaToFolder, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Manga } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
let mangas: Manga[] = [];
let loading = true;
let page = 1;
let hasNextPage = false;
let browseType: BrowseType = "POPULAR";
let search = "";
let searchInput = "";
let ctx: { x: number; y: number; manga: Manga } | null = null;
async function fetchMangas(type: BrowseType, p: number, q: string) {
if (!$store.activeSource) return;
loading = true; mangas = [];
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA, { source: $store.activeSource.id, type, page: p, query: q || null }
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
.catch(console.error)
.finally(() => loading = false);
}
$: if ($store.activeSource) fetchMangas(browseType, page, search);
function submitSearch() {
search = searchInput.trim();
browseType = "SEARCH";
page = 1;
}
function setMode(mode: BrowseType) {
if (mode === browseType) return;
browseType = mode; search = ""; searchInput = ""; page = 1;
}
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(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
.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); } } },
];
}
</script>
{#if $store.activeSource}
<div class="root">
<div class="header">
<button class="back" on:click={() => store.activeSource.set(null)}>
<ArrowLeft size={13} weight="light" /><span>Sources</span>
</button>
<span class="source-name">{$store.activeSource.displayName}</span>
</div>
<div class="toolbar">
<div class="tabs">
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
<button class="tab" class:active={browseType === mode && !search} on:click={() => setMode(mode)}>
{mode.charAt(0) + mode.slice(1).toLowerCase()}
</button>
{/each}
{#if search}<button class="tab active">Search</button>{/if}
</div>
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search source…" bind:value={searchInput}
on:keydown={(e) => e.key === "Enter" && submitSearch()} />
</div>
</div>
{#if loading}
<div class="loading-grid">
{#each Array(18) as _}
<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>
{/each}
</div>
{:else if mangas.length === 0}
<div class="empty">No results.</div>
{:else}
<div class="grid">
{#each mangas as m (m.id)}
<button class="card" on:click={() => { store.activeManga.set(m); store.navPage.set("library"); }}
on:contextmenu={(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" />
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
</div>
<p class="title">{m.title}</p>
</button>
{/each}
</div>
{/if}
{#if !loading && (page > 1 || hasNextPage)}
<div class="pagination">
<button class="page-btn" on:click={() => page = Math.max(1, page - 1)} disabled={page === 1}>
<Prev size={13} weight="light" /> Prev
</button>
<span class="page-num">{page}</span>
<button class="page-btn" on:click={() => page++} disabled={!hasNextPage}>
Next <Next size={13} weight="light" />
</button>
</div>
{/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; 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; transition: color var(--t-base); flex-shrink: 0; }
.back:hover { color: var(--text-secondary); }
.source-name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
.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: 200px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { 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); }
.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%; }
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); padding: var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-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); color: var(--text-muted); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 12px; background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.page-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-raised); }
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
</style>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES } from "../../lib/queries";
import { store } from "../../store/state.svelte";
import type { Source } from "../../lib/types";
let sources: Source[] = $state([]);
let loading = $state(true);
let lang = $state("all");
let search = $state("");
let expanded = $state(new Set<string>());
$effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => { sources = d.sources.nodes; })
.catch(console.error)
.finally(() => { loading = false; });
});
const langs = $derived(["all", ...Array.from(new Set(sources.map((s) => s.lang))).sort()]);
const filtered = $derived(sources.filter((src) => {
if (src.id === "0") return false;
const matchLang = lang === "all" || src.lang === lang;
const matchSearch = src.name.toLowerCase().includes(search.toLowerCase())
|| src.displayName.toLowerCase().includes(search.toLowerCase());
return matchLang && matchSearch;
}));
const groups = $derived.by(() => {
const map = new Map<string, { name: string; icon: string; sources: Source[] }>();
for (const src of filtered) {
if (!map.has(src.name)) map.set(src.name, { name: src.name, icon: src.iconUrl, sources: [] });
map.get(src.name)!.sources.push(src);
}
return Array.from(map.values());
});
function toggleGroup(name: string) {
const next = new Set(expanded);
next.has(name) ? next.delete(name) : next.add(name);
expanded = next;
}
</script>
<div class="root">
<div class="header">
<h1 class="heading">Sources</h1>
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search" bind:value={search} />
</div>
</div>
<div class="lang-row">
{#each langs as l}
<button class="lang-btn" class:active={lang === l} onclick={() => lang = l}>
{l === "all" ? "All" : l.toUpperCase()}
</button>
{/each}
</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 sources found.</div>
{:else}
<div class="list">
{#each groups as g}
{@const single = g.sources.length === 1}
{@const open = expanded.has(g.name)}
<div>
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
<img src={thumbUrl(g.icon)} alt={g.name} class="icon"
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
<div class="info">
<span class="name">{g.name}</span>
<span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span>
</div>
<span class="arrow">
{#if single}{:else if open}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
</span>
</button>
{#if !single && open}
{#each g.sources as src}
<button class="row row-indented" onclick={() => store.activeSource = src}>
<div class="indent-spacer"></div>
<div class="info"><span class="name">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span></div>
<span class="arrow"></span>
</button>
{/each}
{/if}
</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; }
.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: 180px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.lang-row { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-bottom: var(--sp-4); }
.lang-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.lang-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
.lang-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.list { display: flex; flex-direction: column; gap: 1px; }
.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); }
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row-indented { padding-left: var(--sp-5); }
.indent-spacer { width: 32px; flex-shrink: 0; }
.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; }
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.arrow { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.row:hover .arrow { opacity: 1; }
.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>
-387
View File
@@ -1,387 +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);
}
-570
View File
@@ -1,570 +0,0 @@
import { useEffect, useState, useMemo, memo } from "react";
import { ArrowLeft, ArrowRight, Compass, List, BookOpen, Star, Fire } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
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 "./SourceList";
import SourceBrowse from "./SourceBrowse";
import s from "./Explore.module.css";
// ── Frecency ──────────────────────────────────────────────────────────────────
function frecencyScore(readAt: number, count: number): number {
const hoursSince = (Date.now() - readAt) / 3_600_000;
return count / Math.log(hoursSince + 2);
}
// ── Ghost card ────────────────────────────────────────────────────────────────
function GhostCard() {
return <div className={s.ghostCard} aria-hidden />;
}
const GHOST_COUNT = 3;
// ── Skeleton row ──────────────────────────────────────────────────────────────
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>
);
}
// ── Mini card ─────────────────────────────────────────────────────────────────
const MiniCard = memo(function MiniCard({
manga,
onClick,
subtitle,
progress,
}: {
manga: Manga;
onClick: () => void;
subtitle?: string;
progress?: number;
}) {
return (
<button className={s.card} onClick={onClick}>
<div className={s.coverWrap}>
<img
src={thumbUrl(manga.thumbnailUrl)}
alt={manga.title}
className={s.cover}
loading="lazy"
decoding="async"
/>
{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>
);
});
// ── Genre drill-down ──────────────────────────────────────────────────────────
function GenreDrill({
genre,
manga,
sourceManga,
onBack,
onOpen,
}: {
genre: string;
manga: Manga[];
sourceManga: Manga[];
onBack: () => void;
onOpen: (m: Manga) => void;
}) {
const filtered = useMemo(() => {
const combined = new Map<number, Manga>();
[...manga, ...sourceManga]
.filter((m) => (m.genre ?? []).includes(genre))
.forEach((m) => combined.set(m.id, m));
return Array.from(combined.values());
}, [manga, sourceManga, genre]);
return (
<div className={s.drillRoot}>
<div className={s.drillHeader}>
<button className={s.back} onClick={onBack}>
<ArrowLeft size={13} weight="light" />
<span>Explore</span>
</button>
<span className={s.drillTitle}>{genre}</span>
</div>
<div className={s.drillGrid}>
{filtered.map((m) => (
<button key={m.id} className={s.drillCard} onClick={() => onOpen(m)}>
<div className={s.coverWrap}>
<img
src={thumbUrl(m.thumbnailUrl)}
alt={m.title}
className={s.cover}
loading="lazy"
decoding="async"
/>
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
</div>
<p className={s.title}>{m.title}</p>
</button>
))}
{filtered.length === 0 && (
<div className={s.empty}>No manga found for {genre}.</div>
)}
</div>
</div>
);
}
// ── 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 ──────────────────────────────────────────────────────────────────────
type ExploreMode = "explore" | "sources";
type DrillState = { type: "genre"; genre: string } | null;
export default function Explore() {
const [mode, setMode] = useState<ExploreMode>("explore");
const [drill, setDrill] = useState<DrillState>(null);
const activeSource = useStore((s) => s.activeSource);
if (activeSource) return <SourceBrowse />;
if (drill?.type === "genre" && mode === "explore") {
return <DrillWrapper drill={drill} onBack={() => setDrill(null)} />;
}
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>
{mode === "explore" ? <ExploreFeed onDrill={setDrill} /> : <SourceList />}
</div>
);
}
// ── Drill wrapper ─────────────────────────────────────────────────────────────
function DrillWrapper({ drill, onBack }: { drill: DrillState; onBack: () => void }) {
const [allManga, setAllManga] = useState<Manga[]>([]);
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
const setActiveManga = useStore((s) => s.setActiveManga);
const setNavPage = useStore((s) => s.setNavPage);
const settings = useStore((s) => s.settings);
useEffect(() => {
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]));
setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m));
}).catch(console.error);
const preferredLang = settings.preferredExtensionLang || "en";
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => {
const all = d.sources.nodes.filter((src) => src.id !== "0");
const byName = new Map<string, Source[]>();
for (const src of all) {
if (!byName.has(src.name)) byName.set(src.name, []);
byName.get(src.name)!.push(src);
}
const picked: Source[] = [];
for (const group of byName.values()) {
const preferred = group.find((s) => s.lang === preferredLang);
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
}
return Promise.allSettled(
picked.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 seen = new Set<number>();
const merged: Manga[] = [];
for (const r of results) {
if (r.status === "fulfilled")
for (const m of r.value)
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
}
setSourceManga(merged);
})
.catch(console.error);
}, []);
if (!drill) return null;
return (
<GenreDrill
genre={drill.genre}
manga={allManga}
sourceManga={sourceManga}
onBack={onBack}
onOpen={(m) => { setActiveManga(m); setNavPage("library"); }}
/>
);
}
// ── Explore feed ──────────────────────────────────────────────────────────────
function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
const [allManga, setAllManga] = useState<Manga[]>([]);
const [loadingLib, setLoadingLib] = useState(true);
// Popular row: deduped results from POPULAR fetch across all sources
const [popularManga, setPopularManga] = useState<Manga[]>([]);
const [loadingPopular, setLoadingPopular] = useState(true);
// Genre search results: genre → merged Manga[] from SEARCH per source
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
const [loadingGenres, setLoadingGenres] = useState(false);
const [sources, setSources] = useState<Source[]>([]);
const history = useStore((s) => s.history);
const settings = useStore((s) => s.settings);
const setActiveManga = useStore((s) => s.setActiveManga);
const setNavPage = useStore((s) => s.setNavPage);
// Load library
useEffect(() => {
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]));
setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m));
})
.catch(console.error)
.finally(() => setLoadingLib(false));
}, []);
// Load sources → fetch POPULAR from all (for popular row),
// then once we know frecency genres, fire SEARCH per genre per source
useEffect(() => {
const preferredLang = settings.preferredExtensionLang || "en";
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => {
const all = d.sources.nodes.filter((src) => src.id !== "0");
// Dedupe by name, pick preferred lang
const byName = new Map<string, Source[]>();
for (const src of all) {
if (!byName.has(src.name)) byName.set(src.name, []);
byName.get(src.name)!.push(src);
}
const picked: Source[] = [];
for (const group of byName.values()) {
const preferred = group.find((s) => s.lang === preferredLang);
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
}
setSources(picked);
if (picked.length === 0) { setLoadingPopular(false); return; }
// Fetch POPULAR from all sources for the popular row
return Promise.allSettled(
picked.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 seen = new Set<number>();
const merged: Manga[] = [];
for (const r of results)
if (r.status === "fulfilled")
for (const m of r.value)
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
setPopularManga(merged.slice(0, 30));
// Return picked sources for genre search phase
return picked;
});
})
.catch(console.error)
.finally(() => setLoadingPopular(false));
}, []);
// Once library loaded AND sources ready, search each frecency genre across sources
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)));
}
return Array.from(genreWeights.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3) // top 3 genres only
.map(([g]) => g);
}, [allManga, history]);
// Fire genre searches once we have both genres and sources
useEffect(() => {
if (frecencyGenres.length === 0 || sources.length === 0) return;
setLoadingGenres(true);
// For each genre, search all sources concurrently, then merge results
// Cap to top 3 sources to limit requests (3 genres × 3 sources = 9 searches max)
const searchSources = sources.slice(0, 3);
Promise.allSettled(
frecencyGenres.map((genre) =>
Promise.allSettled(
searchSources.map((src) =>
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "SEARCH", page: 1, query: genre,
}).then((d) => d.fetchSourceManga.mangas)
)
).then((results) => {
const seen = new Set<number>();
const merged: Manga[] = [];
for (const r of results)
if (r.status === "fulfilled")
for (const m of r.value)
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
return { genre, mangas: merged.slice(0, 24) };
})
)
).then((results) => {
const map = new Map<string, Manga[]>();
for (const r of results)
if (r.status === "fulfilled")
map.set(r.value.genre, r.value.mangas);
setGenreResults(map);
})
.catch(console.error)
.finally(() => setLoadingGenres(false));
}, [frecencyGenres.join(","), sources.map((s) => s.id).join(",")]);
function openManga(m: Manga) {
setActiveManga(m);
setNavPage("library");
}
// ── 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 (frecency) ──────────────────────────────────────────────
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 = loadingLib || loadingGenres;
return (
<div className={s.body}>
{/* Continue Reading */}
{(continueReading.length > 0 || loadingLib) && (
<Section
title="Continue Reading"
icon={<BookOpen size={11} weight="bold" />}
loading={loadingLib}
>
<div className={s.row}>
{continueReading.map(({ manga, chapterName, progress }) => (
<MiniCard
key={manga.id}
manga={manga}
onClick={() => openManga(manga)}
subtitle={chapterName}
progress={progress}
/>
))}
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
<GhostCard key={`ghost-cr-${i}`} />
))}
</div>
</Section>
)}
{/* Recommended */}
{(recommended.length > 0 || loadingLib) && (
<Section
title="Recommended for You"
icon={<Star size={11} weight="bold" />}
loading={loadingLib}
>
<div className={s.row}>
{recommended.map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
))}
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
<GhostCard key={`ghost-rec-${i}`} />
))}
</div>
</Section>
)}
{/* Popular across deduplicated sources */}
{(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}>
{popularManga.map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
))}
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
<GhostCard key={`ghost-pop-${i}`} />
))}
</div>
)}
</Section>
)}
{/* Genre rows — searched from sources by genre name */}
{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={() => onDrill({ type: "genre", genre })}
loading={isLoading}
>
<div className={s.row}>
{items.map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
))}
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
<GhostCard key={`ghost-${genre}-${i}`} />
))}
</div>
</Section>
);
})}
{/* Empty state */}
{!loadingLib && !loadingPopular && !loadingGenres &&
continueReading.length === 0 && recommended.length === 0 &&
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
<div className={s.empty}>
<span>Nothing to explore yet</span>
<span className={s.emptyHint}>
Add manga to your library or install sources to get started.
</span>
</div>
)}
</div>
);
}

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