Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a8b6e7f93 | |||
| a452cdc2e3 | |||
| 71a6eb02b5 | |||
| 50e981574a |
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to build (e.g. 0.4.0)"
|
description: "Version to build (e.g. 0.3.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -100,86 +100,149 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
|
|
||||||
stage_arch() {
|
find_launcher() {
|
||||||
local srcdir="$1"
|
local dir="$1"
|
||||||
local arch="$2"
|
# v2.1.1867 macOS tarball ships "Suwayomi Launcher.command" (space, .command)
|
||||||
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
find "$dir" -maxdepth 1 -type f -name "*.command" | head -1
|
||||||
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
|
||||||
|
|
||||||
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
|
||||||
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
|
||||||
|
|
||||||
if [ -z "$JAR" ]; then
|
|
||||||
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
|
||||||
find "$srcdir" -type f | head -30
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$JAVA" ]; then
|
|
||||||
echo "ERROR: jre/bin/java not found in $srcdir"
|
|
||||||
find "$srcdir" -type f | head -30
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "${arch}: jar=${JAR} java=${JAVA}"
|
|
||||||
|
|
||||||
cp -r "$srcdir" "$bundle_dest"
|
|
||||||
|
|
||||||
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
|
|
||||||
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
|
|
||||||
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
|
|
||||||
chmod +x "$sidecar"
|
|
||||||
echo "Staged sidecar: $sidecar"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
ARM_LAUNCHER=$(find_launcher suwayomi-arm64)
|
||||||
stage_arch suwayomi-x64 x86_64-apple-darwin
|
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
|
- name: Patch tauri.conf.json for CI
|
||||||
run: |
|
run: |
|
||||||
|
# dist/ is already built by the frontend job — suppress the rebuild.
|
||||||
|
# We patch in-place rather than using --config to avoid Tauri schema issues.
|
||||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
# ── aarch64 build ──────────────────────────────────────────────────────
|
|
||||||
- name: Swap bundle for aarch64
|
- name: Swap bundle for aarch64
|
||||||
run: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
cp -r src-tauri/binaries/suwayomi-bundle-arm64 src-tauri/binaries/suwayomi-bundle
|
||||||
src-tauri/binaries/suwayomi-bundle
|
|
||||||
|
|
||||||
- name: Build Tauri app (aarch64)
|
- name: Build Tauri app (aarch64)
|
||||||
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
# Ad-hoc signing ("-") ships without a Developer ID.
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# Gatekeeper will quarantine the app on other Macs — users must run:
|
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
||||||
# xattr -rd com.apple.quarantine Moku.app
|
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
||||||
# To fix this properly, set APPLE_SIGNING_IDENTITY to your
|
|
||||||
# "Developer ID Application: ..." cert name and add
|
|
||||||
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID /
|
|
||||||
# APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD secrets for notarisation.
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
with:
|
||||||
|
args: --target aarch64-apple-darwin
|
||||||
|
|
||||||
# ── x86_64 build ───────────────────────────────────────────────────────
|
|
||||||
- name: Swap bundle for x86_64
|
- name: Swap bundle for x86_64
|
||||||
run: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
cp -r src-tauri/binaries/suwayomi-bundle-x64 src-tauri/binaries/suwayomi-bundle
|
||||||
src-tauri/binaries/suwayomi-bundle
|
|
||||||
|
|
||||||
- name: Build Tauri app (x86_64)
|
- name: Build Tauri app (x86_64)
|
||||||
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
||||||
|
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
with:
|
||||||
|
args: --target x86_64-apple-darwin
|
||||||
|
|
||||||
# ── upload artifacts ───────────────────────────────────────────────────
|
|
||||||
- name: Upload arm64 .dmg
|
- name: Upload arm64 .dmg
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: moku-macos-arm64-${{ github.event.inputs.version }}
|
name: moku-aarch64
|
||||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload x64 .dmg
|
- name: Upload x64 .dmg
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: moku-macos-x64-${{ github.event.inputs.version }}
|
name: moku-x86_64
|
||||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload arm64 .app (for universal job)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-aarch64-apple-darwin
|
||||||
|
path: src-tauri/target/aarch64-apple-darwin/release/bundle/macos/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Upload x64 .app (for universal job)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-x86_64-apple-darwin
|
||||||
|
path: src-tauri/target/x86_64-apple-darwin/release/bundle/macos/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
universal:
|
||||||
|
name: Universal .dmg
|
||||||
|
needs: tauri
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download arm64 .app
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-aarch64-apple-darwin
|
||||||
|
path: apps/arm64/
|
||||||
|
|
||||||
|
- name: Download x64 .app
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-x86_64-apple-darwin
|
||||||
|
path: apps/x64/
|
||||||
|
|
||||||
|
- name: lipo into universal binary
|
||||||
|
run: |
|
||||||
|
ARM_APP=$(find apps/arm64 -name "*.app" -maxdepth 1 | head -1)
|
||||||
|
X64_APP=$(find apps/x64 -name "*.app" -maxdepth 1 | head -1)
|
||||||
|
APP_NAME=$(basename "$ARM_APP")
|
||||||
|
|
||||||
|
mkdir -p universal
|
||||||
|
cp -r "$ARM_APP" "universal/${APP_NAME}"
|
||||||
|
|
||||||
|
find "universal/${APP_NAME}" -type f | while read -r f; do
|
||||||
|
if file "$f" | grep -q "Mach-O"; then
|
||||||
|
X64_EQUIV="${X64_APP}${f#universal/${APP_NAME}}"
|
||||||
|
if [ -f "$X64_EQUIV" ]; then
|
||||||
|
lipo -create -output "$f" "$f" "$X64_EQUIV" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Package universal .dmg
|
||||||
|
run: |
|
||||||
|
APP_NAME=$(find universal -name "*.app" -maxdepth 1 | head -1 | xargs basename)
|
||||||
|
mkdir dmg-stage
|
||||||
|
cp -r "universal/${APP_NAME}" dmg-stage/
|
||||||
|
ln -s /Applications dmg-stage/Applications
|
||||||
|
hdiutil create \
|
||||||
|
-volname "Moku" \
|
||||||
|
-srcfolder dmg-stage \
|
||||||
|
-ov -format UDZO \
|
||||||
|
"moku-universal.dmg"
|
||||||
|
|
||||||
|
- name: Upload universal .dmg
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: moku-universal
|
||||||
|
path: moku-universal.dmg
|
||||||
|
retention-days: 7
|
||||||
@@ -6,9 +6,10 @@ on:
|
|||||||
version:
|
version:
|
||||||
description: "Version to build (e.g. 0.4.0)"
|
description: "Version to build (e.g. 0.4.0)"
|
||||||
required: true
|
required: true
|
||||||
|
branch:
|
||||||
permissions:
|
description: "Branch to build (e.g. svelte-rewrite)"
|
||||||
contents: write
|
required: false
|
||||||
|
default: "main"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
@@ -16,6 +17,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.branch }}
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
@@ -46,6 +49,8 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.branch }}
|
||||||
|
|
||||||
- name: Download frontend dist
|
- name: Download frontend dist
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -81,82 +86,58 @@ jobs:
|
|||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
||||||
-o suwayomi-windows.zip
|
-o suwayomi-windows.zip
|
||||||
|
|
||||||
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
||||||
|
|
||||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||||
|
|
||||||
- name: Extract Suwayomi bundle
|
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d)
|
||||||
shell: bash
|
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f)
|
||||||
run: |
|
TOP_DIR_COUNT=$(echo "$TOP_DIRS" | grep -c . || true)
|
||||||
|
|
||||||
mkdir -p suwayomi-extracted
|
mkdir -p suwayomi-extracted
|
||||||
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
if [ "$TOP_DIR_COUNT" -eq 1 ] && [ -z "$TOP_FILES" ]; then
|
||||||
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
mv "$TOP_DIRS"/* suwayomi-extracted/
|
||||||
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
|
else
|
||||||
cp -r suwayomi-raw/. suwayomi-extracted/
|
mv suwayomi-raw/* suwayomi-extracted/
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Stage Suwayomi bundle
|
- name: Stage Suwayomi bundle
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mkdir -p src-tauri/binaries
|
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"
|
|
||||||
find suwayomi-extracted -type f | head -50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$JAR" ]; then
|
|
||||||
echo "ERROR: Suwayomi-Server.jar not found"
|
|
||||||
find suwayomi-extracted -type f | head -50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
|
||||||
|
|
||||||
- name: Validate staging
|
JAVAW=$(find suwayomi-extracted -path "*/jre/bin/javaw.exe" | head -1)
|
||||||
shell: bash
|
|
||||||
run: |
|
if [ -z "$JAVAW" ]; then
|
||||||
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
|
echo "ERROR: could not find jre/bin/javaw.exe — bundle contents:"
|
||||||
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
|
find suwayomi-extracted -type f | head -40
|
||||||
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
|
exit 1
|
||||||
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
|
fi
|
||||||
echo "Staging OK"
|
|
||||||
|
echo "Found javaw: $JAVAW"
|
||||||
|
|
||||||
|
# Copy full bundle so jar + jre tree are available at runtime.
|
||||||
|
# lib.rs looks for suwayomi-bundle/jre/bin/javaw.exe in the resource dir.
|
||||||
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
- name: Patch tauri.conf.json for CI
|
- name: Patch tauri.conf.json for CI
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
- name: Delete existing draft release if present
|
- name: Build Tauri app (Windows x64)
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
|
||||||
if [ -n "$RELEASE_ID" ]; then
|
|
||||||
echo "Deleting existing draft release $RELEASE_ID"
|
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID"
|
|
||||||
# Also delete the tag so tauri-action can recreate it
|
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
|
||||||
echo "Deleted draft release and tag"
|
|
||||||
else
|
|
||||||
echo "No existing draft release found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build Tauri app + create draft release
|
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
with:
|
with:
|
||||||
tagName: v${{ github.event.inputs.version }}
|
args: >-
|
||||||
releaseName: Moku v${{ github.event.inputs.version }}
|
--target x86_64-pc-windows-msvc
|
||||||
releaseBody: |
|
--config '{"bundle":{"resources":["binaries/suwayomi-bundle/**"]}}'
|
||||||
Windows installer for Moku v${{ github.event.inputs.version }}.
|
|
||||||
Download the `.exe` file below to install or update.
|
- name: Upload Windows installer
|
||||||
releaseDraft: true
|
uses: actions/upload-artifact@v4
|
||||||
prerelease: false
|
with:
|
||||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
name: moku-windows-x64
|
||||||
|
path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||||
|
retention-days: 7
|
||||||
|
|||||||
@@ -37,7 +37,5 @@ src-tauri/gen/
|
|||||||
# --- Flatpak build artifacts ---
|
# --- Flatpak build artifacts ---
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
dist/
|
|
||||||
packaging/frontend-dist.tar.gz
|
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
.flatpak-builder/
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.5.0
|
pkgver=0.3.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -33,8 +33,14 @@ prepare() {
|
|||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
|
# Repack dist for Tauri
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||||
|
|
||||||
|
# Build Tauri binary
|
||||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||||
--release \
|
--release \
|
||||||
--manifest-path src-tauri/Cargo.toml
|
--manifest-path src-tauri/Cargo.toml
|
||||||
@@ -43,15 +49,19 @@ build() {
|
|||||||
package() {
|
package() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
|
|
||||||
|
# Moku binary
|
||||||
install -Dm755 src-tauri/target/release/moku \
|
install -Dm755 src-tauri/target/release/moku \
|
||||||
"$pkgdir/usr/bin/moku"
|
"$pkgdir/usr/bin/moku"
|
||||||
|
|
||||||
|
# Bundled JRE
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
||||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
||||||
|
|
||||||
|
# Suwayomi server jar
|
||||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
||||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||||
|
|
||||||
|
# tachidesk-server wrapper script
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
@@ -99,6 +109,7 @@ exec /usr/lib/moku/jre/bin/java \
|
|||||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Desktop entry and icons
|
||||||
install -Dm644 packaging/dev.moku.app.desktop \
|
install -Dm644 packaging/dev.moku.app.desktop \
|
||||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
||||||
install -Dm644 src-tauri/icons/32x32.png \
|
install -Dm644 src-tauri/icons/32x32.png \
|
||||||
|
|||||||
@@ -1,140 +1,137 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
<img src="src/assets/rounded-logo.png" width="96" />
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div align="center">
|
<table>
|
||||||
|
<tr>
|
||||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td>
|
||||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
|
||||||
[](./LICENSE)
|
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
|
||||||
[](https://discord.gg/cfncTbJ2)
|
</tr>
|
||||||
|
<tr>
|
||||||
</div>
|
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td>
|
||||||
|
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
|
||||||
<br/>
|
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td>
|
||||||
|
</tr>
|
||||||
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
|
</table>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
|
||||||
<img src="docs/screenshots/Moku-Discover.png" width="49%" alt="Discover" />
|
|
||||||
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
|
||||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
|
||||||
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
|
||||||
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<a href="docs/screenshots">View all screenshots →</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
### Reader
|
||||||
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
- **Single**, **double-page**, and **longstrip** reading modes
|
||||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
- **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
|
||||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
- Fit modes: fit width, fit height, fit screen, and 1:1 original
|
||||||
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
- Per-series zoom control via Ctrl+scroll or a slider popover
|
||||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
- RTL / LTR reading direction toggle
|
||||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
- Configurable page gaps
|
||||||
- **Auto-updates** — in-app update checker with silent background notifications
|
- 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
|
||||||
|
|
||||||
## Installation
|
### 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
|
||||||
|
|
||||||
### Flatpak (Linux, recommended)
|
### 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
|
||||||
|
|
||||||
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
### 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
|
||||||
|
|
||||||
```bash
|
### Downloads
|
||||||
flatpak install moku.flatpak
|
- Download queue with live progress
|
||||||
flatpak run dev.moku.app
|
|
||||||
```
|
|
||||||
|
|
||||||
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
### History
|
||||||
|
- Reading history grouped by day with relative timestamps
|
||||||
### Nix
|
- Per-entry thumbnail, chapter name, and last-read page
|
||||||
|
- Full-text search across titles and chapter names
|
||||||
```bash
|
- One-click clear
|
||||||
nix run github:Youwes09/Moku
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to your flake:
|
|
||||||
|
|
||||||
```nix
|
|
||||||
inputs.moku.url = "github:Youwes09/Moku";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
|
||||||
|
|
||||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
|
||||||
> ```bash
|
|
||||||
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
|
||||||
> ```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
||||||
|
|
||||||
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
**Nix (recommended)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run github:Youwes09/moku
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to your flake:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
inputs.moku.url = "github:Youwes09/moku";
|
||||||
|
```
|
||||||
|
|
||||||
|
**From source**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Youwes09/moku
|
||||||
|
cd moku
|
||||||
|
nix build
|
||||||
|
./result/bin/moku
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/Youwes09/Moku
|
|
||||||
cd Moku
|
|
||||||
pnpm install
|
|
||||||
pnpm tauri:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Or with Nix:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix develop
|
nix develop
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm tauri:dev
|
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
|
## Stack
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||||
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
| [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 |
|
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Community
|
|
||||||
|
|
||||||
Questions, feedback, or just want to hang out — join the Discord.
|
|
||||||
|
|
||||||
[](https://discord.gg/cfncTbJ2)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||||
|
|||||||
@@ -1,42 +1,104 @@
|
|||||||
Major Revisions:
|
Todo:
|
||||||
- Moku + Crossplatform Support (MacOS Remaining)
|
3. Explore Manga Upscaler & Other Image Processing
|
||||||
- Contemplate Anime Support, Add Novel Support (Consumet API)
|
4. Font Weird on Flatpak, Investigate and Fix
|
||||||
- Enable Cloudflare Bypass (Suwayomi Config)
|
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
|
||||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
|
||||||
- Adjustment in Settings for Theme Editor:
|
|
||||||
- Allow User to Edit/Create Themes
|
Bugs:
|
||||||
- Allow For Command-Line IPC for Temporary (Apply Once) & Permanent Themes OR External Methodology for Matugen Colors
|
|
||||||
|
- 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:
|
||||||
|
- Add PDF Textbook Support
|
||||||
|
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
||||||
|
- Migration Features
|
||||||
|
- Multi-Page Long Screenshot
|
||||||
|
- Add Consumet Api (Anime & Light Novel Support)
|
||||||
|
|
||||||
|
|
||||||
|
Big Revisions:
|
||||||
|
0. Expand into fully-fledged reader, with modular manga support
|
||||||
|
1. Anime & Novel Support
|
||||||
|
2. Tracker Support
|
||||||
|
3. Cloudflare Bypass Enable Support
|
||||||
|
4. macOS Support (feasible)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Testing:
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
|
||||||
Minor Revisions:
|
|
||||||
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
|
||||||
- Integrate Download Directory Changes (Settings)
|
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
|
||||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
|
||||||
|
|
||||||
Priority Bugs:
|
|
||||||
- Cache ALL Cover Pictures & Details for Manga in Library
|
|
||||||
- MacOS Full-Screen & UI Compatability (TitleBar)
|
|
||||||
|
|
||||||
General/Misc Bugs:
|
|
||||||
- Fix Highlightable Elements
|
|
||||||
- Investigate "egl:failed to create dri2 screen"
|
|
||||||
- Check Fonts/Design on Flatpak
|
|
||||||
- Fix Delete-All Crash (Deletes All but Cripples App)
|
|
||||||
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
|
||||||
|
|
||||||
In-Progress:`
|
|
||||||
- Fix Reader Chapter Shifts (Glitched Sentinel)
|
|
||||||
- Still Shifts Down after reading ~8+ Chapters?
|
|
||||||
- Identify When Chapters are Unloaded, How to Preserve Structure
|
|
||||||
|
|
||||||
|
|
||||||
Important Commands:
|
Important Commands:
|
||||||
cd ~/Projects/Manga/Moku
|
cd ~/Projects/Manga/Moku
|
||||||
pnpm build
|
pnpm build
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
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"
|
||||||
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
|
||||||
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
|
||||||
flatpak build-bundle repo moku.flatpak dev.moku.app
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-scripts/pkgbuild-bump.sh
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Run this AFTER the git tag has been pushed to GitHub.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-scripts/pkgbuild-bump.sh 0.3.0
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||||
|
info() { echo -e "${CYAN} →${RESET} $*"; }
|
||||||
|
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
||||||
|
die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
||||||
|
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && die "Usage: $0 <version>"
|
||||||
|
VERSION="$1"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
PKGBUILD="${REPO_ROOT}/PKGBUILD"
|
||||||
|
|
||||||
|
command -v curl &>/dev/null || die "curl not found"
|
||||||
|
[[ -f "$PKGBUILD" ]] || die "PKGBUILD not found: $PKGBUILD"
|
||||||
|
|
||||||
|
section "Patching PKGBUILD → ${VERSION}"
|
||||||
|
|
||||||
|
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v${VERSION}.tar.gz"
|
||||||
|
info "Fetching source tarball to compute 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"
|
||||||
|
|
||||||
|
# Replace only the first sha256 entry (source tarball) inside sha256sums=('...')
|
||||||
|
# The suwayomi jar and jdk hashes are pinned and stay untouched.
|
||||||
|
# Strategy: match the opening sha256sums=('' then swap just that first hash.
|
||||||
|
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1${TARBALL_SHA}/" "$PKGBUILD"
|
||||||
|
|
||||||
|
# Verify the replacement landed
|
||||||
|
if ! grep -q "$TARBALL_SHA" "$PKGBUILD"; then
|
||||||
|
die "sha256 replacement failed — check PKGBUILD sha256sums format"
|
||||||
|
fi
|
||||||
|
|
||||||
|
success "PKGBUILD patched (pkgver=${VERSION}, sha256=${TARBALL_SHA})"
|
||||||
|
info "PKGBUILD → ${PKGBUILD}"
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-scripts/release.sh
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Usage:
|
||||||
|
# ./build-scripts/release.sh 0.2.0
|
||||||
|
#
|
||||||
|
# Requires: nix, flatpak-builder, appstream
|
||||||
|
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>"
|
||||||
|
VERSION="$1"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
|
||||||
|
PKGBUILD="${REPO_ROOT}/PKGBUILD"
|
||||||
|
|
||||||
|
# ── Sanity checks ──────────────────────────────────────────────────────────────
|
||||||
|
section "Pre-flight"
|
||||||
|
command -v nix &>/dev/null || die "nix not found"
|
||||||
|
command -v curl &>/dev/null || die "curl not found"
|
||||||
|
[[ -f "$FLATPAK_MANIFEST" ]] || die "Flatpak manifest not found: $FLATPAK_MANIFEST"
|
||||||
|
[[ -f "$PKGBUILD" ]] || die "PKGBUILD not found: $PKGBUILD"
|
||||||
|
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}"
|
||||||
|
|
||||||
|
# flake.nix has two `version = "x.y.z";` strings inside the frontend
|
||||||
|
# derivation and fetchPnpmDeps — both need to match.
|
||||||
|
sed -i "s/version = \"[^\"]*\";/version = \"${VERSION}\";/g" \
|
||||||
|
"${REPO_ROOT}/flake.nix"
|
||||||
|
success "flake.nix → ${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/"
|
||||||
|
|
||||||
|
# ── Flatpak ────────────────────────────────────────────────────────────────────
|
||||||
|
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}"
|
||||||
|
|
||||||
|
section "Patching frontend-dist sha256 in dev.moku.app.yml"
|
||||||
|
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
|
||||||
|
|
||||||
|
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
||||||
|
success "moku.flatpak created"
|
||||||
|
|
||||||
|
# ── Done ───────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
success "v${VERSION} ready"
|
||||||
|
info "Flatpak bundle → ${REPO_ROOT}/moku.flatpak"
|
||||||
|
echo ""
|
||||||
|
warn "PKGBUILD not patched yet — tag must exist on GitHub first."
|
||||||
|
info "After pushing the tag, run:"
|
||||||
|
echo -e " ${CYAN}./build-scripts/pkgbuild-bump.sh ${VERSION}${RESET}"
|
||||||
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 3ac5d822ac1840473333510b5e45220298702e6d1435e2cdd4b5c2f7195d764f
|
sha256: c9bb5ee6613b2bc61e69a92cc1ef0029da3b61138d51b01d363f8ea524e51996
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
|
|
||||||
<defs>
|
|
||||||
|
|
||||||
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#52b888"/>
|
|
||||||
<stop offset="100%" stop-color="#1e5840"/>
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<clipPath id="roundedBounds">
|
|
||||||
<rect width="1280" height="320" rx="18" ry="18"/>
|
|
||||||
</clipPath>
|
|
||||||
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<g clip-path="url(#roundedBounds)">
|
|
||||||
|
|
||||||
<rect width="1280" height="320" fill="#070e09"/>
|
|
||||||
|
|
||||||
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
|
|
||||||
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
|
|
||||||
fill="url(#leafHero)" opacity="0.97">
|
|
||||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
|
||||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
|
||||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
|
||||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
|
||||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
|
||||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
|
||||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
|
||||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
|
||||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
|
||||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
|
||||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
|
||||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
|
||||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
|
||||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
|
||||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
|
||||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
|
||||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Stack text pinned to bottom -->
|
|
||||||
<text
|
|
||||||
x="640" y="300"
|
|
||||||
text-anchor="middle"
|
|
||||||
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
|
|
||||||
font-size="14"
|
|
||||||
letter-spacing="5"
|
|
||||||
fill="#a8c4a8"
|
|
||||||
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.1 MiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 914 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 648 KiB |
|
Before Width: | Height: | Size: 609 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 151 KiB |
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773857772,
|
"lastModified": 1771438068,
|
||||||
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -15,16 +15,32 @@
|
|||||||
"type": "github"
|
"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": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772408722,
|
"lastModified": 1769996383,
|
||||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -33,13 +49,53 @@
|
|||||||
"type": "github"
|
"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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773821835,
|
"lastModified": 1771369470,
|
||||||
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -51,11 +107,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772328832,
|
"lastModified": 1769909678,
|
||||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -68,6 +124,7 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"crane": "crane",
|
"crane": "crane",
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
|
"nix-appimage": "nix-appimage",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
}
|
}
|
||||||
@@ -79,11 +136,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773975983,
|
"lastModified": 1771556776,
|
||||||
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -91,6 +148,21 @@
|
|||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"type": "github"
|
"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",
|
"root": "root",
|
||||||
|
|||||||
@@ -2,34 +2,43 @@
|
|||||||
description = "Moku — manga reader frontend for Suwayomi";
|
description = "Moku — manga reader frontend for Suwayomi";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
crane.url = "github:ipetkov/crane";
|
crane.url = "github:ipetkov/crane";
|
||||||
rust-overlay = {
|
rust-overlay = {
|
||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
nix-appimage = {
|
||||||
|
url = "github:ralismark/nix-appimage";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
inputs@{ flake-parts, crane, rust-overlay, nix-appimage, ... }:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
];
|
||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem =
|
||||||
|
{ system, pkgs, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.5.0";
|
pkgs' = import inputs.nixpkgs {
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
|
||||||
inherit system;
|
inherit system;
|
||||||
overlays = [ rust-overlay.overlays.default ];
|
overlays = [ rust-overlay.overlays.default ];
|
||||||
};
|
};
|
||||||
|
|
||||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
rustToolchain = pkgs'.rust-bin.stable.latest.default.override {
|
||||||
extensions = [ "rust-src" "rust-analyzer" ];
|
extensions = [
|
||||||
|
"rust-src"
|
||||||
|
"rust-analyzer"
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
craneLib = (crane.mkLib pkgs').overrideToolchain rustToolchain;
|
||||||
|
|
||||||
runtimeLibs = with pkgs; [
|
runtimeLibs = with pkgs; [
|
||||||
webkitgtk_4_1
|
webkitgtk_4_1
|
||||||
@@ -56,44 +65,56 @@
|
|||||||
|| base == "package.json"
|
|| base == "package.json"
|
||||||
|| base == "pnpm-lock.yaml"
|
|| base == "pnpm-lock.yaml"
|
||||||
|| base == "tsconfig.json"
|
|| base == "tsconfig.json"
|
||||||
|| base == "vite.config.ts";
|
|| base == "tsconfig.node.json"
|
||||||
|
|| base == "vite.config.ts"
|
||||||
|
|| base == "postcss.config.js"
|
||||||
|
|| base == "postcss.config.cjs"
|
||||||
|
|| base == "tailwind.config.js"
|
||||||
|
|| base == "tailwind.config.ts";
|
||||||
};
|
};
|
||||||
|
|
||||||
frontend = pkgs.stdenv.mkDerivation {
|
frontend = pkgs.stdenv.mkDerivation {
|
||||||
pname = "moku-frontend";
|
pname = "moku-frontend";
|
||||||
inherit version;
|
version = "0.3.0";
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
nativeBuildInputs = with pkgs; [
|
||||||
|
nodejs_22
|
||||||
|
pnpm
|
||||||
|
pnpmConfigHook
|
||||||
|
];
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
pname = "moku-frontend";
|
pname = "moku-frontend";
|
||||||
inherit version;
|
version = "0.3.0";
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 1;
|
||||||
hash = "sha256-4QUSgWgMu7FGn44+TGmACheokPhaBdHvA/055SqUs0Q=";
|
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
buildPhase = "pnpm build";
|
||||||
installPhase = "cp -r dist $out";
|
installPhase = "cp -r dist $out";
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoSrc = lib.cleanSourceWith {
|
cargoSrc = lib.cleanSourceWith {
|
||||||
src = ./src-tauri;
|
src = ./src-tauri;
|
||||||
filter = path: type:
|
filter = path: type:
|
||||||
(craneLib.filterCargoSources path type)
|
(craneLib.filterCargoSources path type)
|
||||||
|| (lib.hasInfix "/icons/" path)
|
|| (lib.hasInfix "/icons/" path)
|
||||||
|| (lib.hasInfix "/capabilities/" path)
|
|| (lib.hasInfix "/capabilities/" path)
|
||||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
|| (builtins.baseNameOf path == "tauri.conf.json");
|
||||||
};
|
};
|
||||||
|
|
||||||
commonArgs = {
|
commonArgs = {
|
||||||
src = cargoSrc;
|
src = cargoSrc;
|
||||||
cargoToml = ./src-tauri/Cargo.toml;
|
cargoToml = ./src-tauri/Cargo.toml;
|
||||||
cargoLock = ./src-tauri/Cargo.lock;
|
cargoLock = ./src-tauri/Cargo.lock;
|
||||||
strictDeps = true;
|
strictDeps = true;
|
||||||
buildInputs = runtimeLibs;
|
buildInputs = runtimeLibs;
|
||||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
nativeBuildInputs = with pkgs; [
|
||||||
|
pkg-config
|
||||||
|
wrapGAppsHook3
|
||||||
|
];
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r ${frontend} ../dist
|
cp -r ${frontend} ../dist
|
||||||
'';
|
'';
|
||||||
@@ -105,36 +126,6 @@
|
|||||||
inherit cargoArtifacts;
|
inherit cargoArtifacts;
|
||||||
meta.mainProgram = "moku";
|
meta.mainProgram = "moku";
|
||||||
postInstall = ''
|
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 \
|
wrapProgram $out/bin/moku \
|
||||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||||
pkgs.gsettings-desktop-schemas
|
pkgs.gsettings-desktop-schemas
|
||||||
@@ -143,139 +134,72 @@ EOF
|
|||||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
--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 GDK_BACKEND wayland \
|
||||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
--set WEBKIT_FORCE_SANDBOX 0
|
||||||
|
|
||||||
|
# ── Icon ─────────────────────────────────────────────────────────
|
||||||
|
# Tauri bakes several sizes into src-tauri/icons/. We prefer the
|
||||||
|
# largest PNG (512x512) for the hicolor theme, and also install the
|
||||||
|
# rounded 32x32 used as the in-app logo so small sizes look right.
|
||||||
|
# Adjust the source filenames if yours differ.
|
||||||
|
for size in 32x32 128x128 256x256 512x512; do
|
||||||
|
src="icons/$size.png"
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
install -Dm644 "$src" \
|
||||||
|
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# @2x variants that Tauri also generates
|
||||||
|
for size in 128x128 256x256; do
|
||||||
|
src="icons/''${size}@2x.png"
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
install -Dm644 "$src" \
|
||||||
|
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Scalable SVG — src/assets/moku-icon.svg is the rounded version
|
||||||
|
# referenced in SplashScreen.tsx. Pull it straight from the source
|
||||||
|
# tree so the launcher always uses the same rounded artwork.
|
||||||
|
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
||||||
|
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||||
|
|
||||||
|
# ── .desktop entry ───────────────────────────────────────────────
|
||||||
|
install -Dm644 /dev/stdin \
|
||||||
|
"$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
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
in
|
||||||
{
|
{
|
||||||
|
# Expose as both a runnable app and installable packages.
|
||||||
apps = {
|
apps = {
|
||||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
default = {
|
||||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
type = "app";
|
||||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
program = "${moku}/bin/moku";
|
||||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
};
|
||||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
moku = {
|
||||||
|
type = "app";
|
||||||
|
program = "${moku}/bin/moku";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
packages = {
|
packages = {
|
||||||
inherit moku frontend;
|
inherit moku frontend;
|
||||||
default = moku;
|
default = moku;
|
||||||
|
appimage = nix-appimage.bundlers."${system}".default moku;
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
@@ -290,16 +214,26 @@ EOF
|
|||||||
xdg-utils
|
xdg-utils
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
export NO_STRIP=true
|
export NO_STRIP=true
|
||||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
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}"
|
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}"
|
||||||
|
|
||||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
if [ ! -e /usr/bin/xdg-open ]; then
|
||||||
echo ""
|
sudo ln -sf ${pkgs.xdg-utils}/bin/xdg-open /usr/bin/xdg-open
|
||||||
echo "Release:"
|
fi
|
||||||
echo " nix run .#bump -- <ver> bump versions only"
|
|
||||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage"
|
||||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
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"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>Moku</title>
|
<title>Moku</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,28 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "moku",
|
"name": "moku",
|
||||||
"version": "0.5.0",
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||||
|
"tauri:build": "tauri build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-shell": "~2",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"phosphor-svelte": "^3.1.0",
|
"lucide-react": "^0.575.0",
|
||||||
"svelte-spa-router": "^4.0.1"
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"svelte": "^5.0.0",
|
"@types/react": "^18.3.3",
|
||||||
"svelte-check": "^3.0.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"typescript": "^5.0.0",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"vite": "^5.0.0"
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.40",
|
||||||
|
"tailwindcss": "^3.4.7",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
<releases>
|
<releases>
|
||||||
<release version="0.4.0" date="2025-03-22">
|
<release version="0.1.0" date="2025-01-01">
|
||||||
<description>
|
<description>
|
||||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
<p>Initial release.</p>
|
||||||
</description>
|
</description>
|
||||||
</release>
|
</release>
|
||||||
</releases>
|
</releases>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.5.0"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "moku_lib"
|
name = "moku_lib"
|
||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
@@ -16,16 +16,12 @@ tauri-build = { version = "2.0", features = [] }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0", features = [] }
|
tauri = { version = "2.0", features = [] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
|
||||||
tauri-plugin-process = "2"
|
|
||||||
tauri-plugin-http = "2"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
sysinfo = "0.32"
|
sysinfo = "0.32"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-os = "2.3.2"
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Moku — Suwayomi launcher sidecar for macOS.
|
|
||||||
# Tauri calls this script directly as a sidecar (Contents/MacOS/suwayomi-server-{arch}).
|
|
||||||
# The Suwayomi bundle is placed by Tauri into Contents/Resources/suwayomi-bundle/.
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Resolve the real directory of this script, following symlinks.
|
|
||||||
SELF="$0"
|
|
||||||
while [ -L "$SELF" ]; do
|
|
||||||
SELF="$(readlink "$SELF")"
|
|
||||||
done
|
|
||||||
DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
|
||||||
|
|
||||||
# ── Locate the bundle ─────────────────────────────────────────────────────────
|
|
||||||
# Inside .app: sidecar = Contents/MacOS/suwayomi-server-{arch}
|
|
||||||
# bundle = Contents/Resources/suwayomi-bundle/
|
|
||||||
# Dev / flat layout: bundle sits next to the sidecar, or one level up.
|
|
||||||
find_bundle() {
|
|
||||||
local base="$1"
|
|
||||||
for candidate in \
|
|
||||||
"${base}/../Resources/suwayomi-bundle" \
|
|
||||||
"${base}/suwayomi-bundle" \
|
|
||||||
"${base}/../suwayomi-bundle"
|
|
||||||
do
|
|
||||||
# The jar lives at <bundle>/bin/Suwayomi-Server.jar
|
|
||||||
if [ -f "${candidate}/bin/Suwayomi-Server.jar" ]; then
|
|
||||||
# Canonicalise (no readlink -f on older macOS sh, use cd trick)
|
|
||||||
echo "$(cd "$candidate" && pwd)"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
BUNDLE=$(find_bundle "$DIR") || {
|
|
||||||
echo "[sidecar] ERROR: cannot locate suwayomi-bundle relative to $DIR" >&2
|
|
||||||
echo "[sidecar] Tried:" >&2
|
|
||||||
echo " $DIR/../Resources/suwayomi-bundle" >&2
|
|
||||||
echo " $DIR/suwayomi-bundle" >&2
|
|
||||||
echo " $DIR/../suwayomi-bundle" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
JAVA="${BUNDLE}/jre/bin/java"
|
|
||||||
JAR="${BUNDLE}/bin/Suwayomi-Server.jar"
|
|
||||||
|
|
||||||
echo "[sidecar] BUNDLE=$BUNDLE" >&2
|
|
||||||
echo "[sidecar] JAVA=$JAVA" >&2
|
|
||||||
echo "[sidecar] JAR=$JAR" >&2
|
|
||||||
|
|
||||||
if [ ! -x "$JAVA" ]; then
|
|
||||||
echo "[sidecar] ERROR: java not executable at $JAVA" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ ! -f "$JAR" ]; then
|
|
||||||
echo "[sidecar] ERROR: jar not found at $JAR" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# "$@" will contain the -Dsuwayomi.tachidesk.config.server.rootDir=... flag
|
|
||||||
# prepended by spawn_server in lib.rs, followed by -jar <path>.
|
|
||||||
# We call java directly so all JVM flags reach it properly.
|
|
||||||
exec "$JAVA" \
|
|
||||||
-Djava.awt.headless=true \
|
|
||||||
"$@" \
|
|
||||||
-jar "$JAR"
|
|
||||||
@@ -1,37 +1,20 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default permissions for Moku",
|
"description": "Allow launching suwayomi-server sidecar",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"shell:allow-kill",
|
"shell:allow-kill",
|
||||||
"shell:allow-spawn",
|
{
|
||||||
"shell:allow-execute",
|
"identifier": "shell:allow-spawn",
|
||||||
"core:window:allow-minimize",
|
"allow": [
|
||||||
"core:window:allow-unminimize",
|
{
|
||||||
"core:window:allow-maximize",
|
"name": "binaries/suwayomi-server",
|
||||||
"core:window:allow-unmaximize",
|
"sidecar": true
|
||||||
"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",
|
|
||||||
"updater:default",
|
|
||||||
"updater:allow-check",
|
|
||||||
"updater:allow-download-and-install",
|
|
||||||
"process:default",
|
|
||||||
"process:allow-restart",
|
|
||||||
"http:default",
|
|
||||||
"http:allow-fetch"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 740 B After Width: | Height: | Size: 803 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 706 B |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -1,11 +1,8 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::io::Write;
|
|
||||||
use sysinfo::Disks;
|
use sysinfo::Disks;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{Manager, WindowEvent};
|
use tauri::{Manager, WindowEvent};
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
use tauri::Emitter;
|
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -19,51 +16,16 @@ pub struct StorageInfo {
|
|||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
|
||||||
#[serde(tag = "kind", content = "message")]
|
|
||||||
pub enum SpawnError {
|
|
||||||
NotConfigured(String),
|
|
||||||
SpawnFailed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Update types ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A single GitHub release returned to the frontend.
|
|
||||||
#[derive(Serialize, Clone)]
|
|
||||||
pub struct ReleaseInfo {
|
|
||||||
pub tag_name: String,
|
|
||||||
pub name: String,
|
|
||||||
pub body: String,
|
|
||||||
pub published_at: String,
|
|
||||||
pub html_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Progress event emitted during download — matches what the frontend listens for.
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
||||||
struct UpdateProgress {
|
|
||||||
downloaded: u64,
|
|
||||||
total: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||||
if !downloads_path.trim().is_empty() {
|
if !downloads_path.trim().is_empty() {
|
||||||
return PathBuf::from(downloads_path);
|
return PathBuf::from(downloads_path);
|
||||||
}
|
}
|
||||||
let base = std::env::var("XDG_DATA_HOME")
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
.unwrap_or_else(|_| {
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/"))
|
||||||
|
});
|
||||||
base.join("Tachidesk/downloads")
|
base.join("Tachidesk/downloads")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +45,7 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
let stat_path = if path.exists() {
|
let stat_path = if path.exists() { path.clone() } else {
|
||||||
path.clone()
|
|
||||||
} else {
|
|
||||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,64 +56,47 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||||
|
|
||||||
|
let total_bytes = disk.total_space();
|
||||||
|
let free_bytes = disk.available_space();
|
||||||
|
|
||||||
Ok(StorageInfo {
|
Ok(StorageInfo {
|
||||||
manga_bytes,
|
manga_bytes,
|
||||||
total_bytes: disk.total_space(),
|
total_bytes,
|
||||||
free_bytes: disk.available_space(),
|
free_bytes,
|
||||||
path: path.to_string_lossy().into_owned(),
|
path: path.to_string_lossy().into_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the true OS-level scale factor for the main window.
|
||||||
|
/// On Linux this bypasses WebKitGTK's unreliable devicePixelRatio.
|
||||||
|
/// On macOS the value comes directly from the native window.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_platform_ui_scale() -> f64 {
|
fn get_scale_factor(window: tauri::Window) -> f64 {
|
||||||
#[cfg(target_os = "windows")]
|
window.scale_factor().unwrap_or(1.0)
|
||||||
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) {
|
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||||
let state = app.state::<ServerState>();
|
let state = app.state::<ServerState>();
|
||||||
if let Some(child) = state.0.lock().unwrap().take() {
|
let mut guard = state.0.lock().unwrap();
|
||||||
|
if let Some(child) = guard.take() {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
|
println!("Killed tracked server child.");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
let _ = std::process::Command::new("taskkill")
|
||||||
use std::os::windows::process::CommandExt;
|
.args(["/F", "/FI", "IMAGENAME eq tachidesk*"])
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
.status();
|
||||||
|
|
||||||
let _ = std::process::Command::new("taskkill")
|
|
||||||
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.status();
|
|
||||||
|
|
||||||
// Poll until no java.exe remains (up to ~3 s) so the installer can
|
|
||||||
// overwrite the JRE DLLs without hitting a sharing-violation error.
|
|
||||||
for _ in 0..30 {
|
|
||||||
let still_running = std::process::Command::new("tasklist")
|
|
||||||
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.output()
|
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !still_running {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let _ = std::process::Command::new("pkill")
|
let _ = std::process::Command::new("pkill")
|
||||||
.args(["-f", "tachidesk"])
|
.arg("-f")
|
||||||
|
.arg("tachidesk")
|
||||||
.status();
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The default server.conf we seed on first launch.
|
||||||
|
/// Mirrors the Flatpak wrapper: headless, no tray, no browser pop-up.
|
||||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = false
|
||||||
@@ -171,6 +114,9 @@ server.maxSourcesInParallel = 6
|
|||||||
server.extensionRepos = []
|
server.extensionRepos = []
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Ensure the Suwayomi data dir and server.conf exist, and that the three
|
||||||
|
/// keys that cause GUI/JCEF crashes are always set to safe values.
|
||||||
|
/// This mirrors the shell-script logic in the Flatpak's tachidesk-server wrapper.
|
||||||
fn seed_server_conf(data_dir: &PathBuf) {
|
fn seed_server_conf(data_dir: &PathBuf) {
|
||||||
let conf_path = data_dir.join("server.conf");
|
let conf_path = data_dir.join("server.conf");
|
||||||
|
|
||||||
@@ -185,408 +131,216 @@ fn seed_server_conf(data_dir: &PathBuf) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conf already exists — patch the three critical keys in-place.
|
||||||
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
||||||
|
|
||||||
let patched = patch_conf_key(
|
let patched = patch_conf_key(
|
||||||
patch_conf_key(
|
patch_conf_key(
|
||||||
patch_conf_key(contents, "server.webUIEnabled", "false"),
|
patch_conf_key(
|
||||||
"server.initialOpenInBrowserEnabled", "false",
|
contents,
|
||||||
|
"server.webUIEnabled",
|
||||||
|
"false",
|
||||||
|
),
|
||||||
|
"server.initialOpenInBrowserEnabled",
|
||||||
|
"false",
|
||||||
),
|
),
|
||||||
"server.systemTrayEnabled", "false",
|
"server.systemTrayEnabled",
|
||||||
|
"false",
|
||||||
);
|
);
|
||||||
|
|
||||||
let _ = std::fs::write(&conf_path, patched);
|
let _ = std::fs::write(&conf_path, patched);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
/// Replace `key = <value>` in a HOCON/properties-style conf, or append it
|
||||||
|
/// if the key is absent.
|
||||||
|
fn patch_conf_key(mut text: String, key: &str, value: &str) -> String {
|
||||||
let replacement = format!("{key} = {value}");
|
let replacement = format!("{key} = {value}");
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
// Find a line that starts with the key (tolerant of surrounding whitespace)
|
||||||
|
if let Some(pos) = text.lines().position(|l| l.trim_start().starts_with(key)) {
|
||||||
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
let mut out = lines
|
// We need an owned replacement; rebuild from scratch.
|
||||||
|
let owned: Vec<String> = lines
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
.map(|(i, l)| {
|
||||||
.collect::<Vec<_>>()
|
if i == pos { replacement.clone() } else { l.to_string() }
|
||||||
.join("\n");
|
})
|
||||||
out.push('\n');
|
.collect();
|
||||||
return out;
|
return owned.join("\n");
|
||||||
}
|
}
|
||||||
|
// Key absent — append.
|
||||||
let mut out = text;
|
if !text.ends_with('\n') { text.push('\n'); }
|
||||||
if !out.ends_with('\n') { out.push('\n'); }
|
text.push_str(&replacement);
|
||||||
out.push_str(&replacement);
|
text.push('\n');
|
||||||
out.push('\n');
|
text
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the Suwayomi data directory.
|
||||||
|
///
|
||||||
|
/// - Linux: $XDG_DATA_HOME/moku/tachidesk (matches Flatpak path)
|
||||||
|
/// - macOS: ~/Library/Application Support/dev.moku.app/tachidesk
|
||||||
fn suwayomi_data_dir() -> PathBuf {
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
dirs::data_dir()
|
dirs::data_dir()
|
||||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||||
.join("dev.moku.app/tachidesk")
|
.join("dev.moku.app/tachidesk")
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
{
|
{
|
||||||
let base = std::env::var("XDG_DATA_HOME")
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
.unwrap_or_else(|_| {
|
||||||
|
dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||||
|
});
|
||||||
base.join("moku/tachidesk")
|
base.join("moku/tachidesk")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything needed to spawn the server process.
|
||||||
struct ServerInvocation {
|
struct ServerInvocation {
|
||||||
bin: String,
|
/// Path to the executable (javaw.exe on Windows, the sidecar script on macOS/Linux).
|
||||||
args: Vec<String>,
|
bin: std::ffi::OsString,
|
||||||
|
/// Extra args prepended before the Suwayomi rootDir flag.
|
||||||
|
/// On Windows: ["-jar", "<path-to-jar>"]
|
||||||
|
/// Elsewhere: []
|
||||||
|
prefix_args: Vec<String>,
|
||||||
|
/// Working directory for the child process.
|
||||||
|
/// On Windows this must be the bundle folder so javaw can find the JRE and jar.
|
||||||
|
/// Elsewhere: None (inherit).
|
||||||
working_dir: Option<PathBuf>,
|
working_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
/// Resolve the server binary path.
|
||||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
///
|
||||||
#[cfg(target_os = "windows")]
|
/// If the frontend passes a non-empty `binary` string (user override in
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java.exe");
|
/// Settings) we always use that — on Linux this is the nixpkgs/Flatpak path.
|
||||||
|
///
|
||||||
#[cfg(not(target_os = "windows"))]
|
/// Otherwise we look for the Tauri-bundled sidecar inside the resource dir
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
/// and, on Windows, build the javaw + jar invocation from the suwayomi-bundle.
|
||||||
|
|
||||||
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(
|
fn resolve_server_binary(
|
||||||
binary: &str,
|
binary: &str,
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
log: &mut Option<std::fs::File>,
|
) -> Result<ServerInvocation, String> {
|
||||||
) -> Result<ServerInvocation, SpawnError> {
|
|
||||||
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
|
||||||
|
|
||||||
if !binary.trim().is_empty() {
|
if !binary.trim().is_empty() {
|
||||||
do_log(log, "[resolve] using user-supplied binary path");
|
|
||||||
return Ok(ServerInvocation {
|
return Ok(ServerInvocation {
|
||||||
bin: binary.to_string(),
|
bin: std::ffi::OsString::from(binary),
|
||||||
args: vec![],
|
prefix_args: vec![],
|
||||||
working_dir: None,
|
working_dir: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let resource_dir = match app.path().resource_dir() {
|
let resource_dir = app
|
||||||
Ok(p) => {
|
.path()
|
||||||
let stripped = strip_unc(p);
|
.resource_dir()
|
||||||
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
|
.map_err(|e| format!("Could not locate resource dir: {e}"))?;
|
||||||
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"))]
|
// ── Windows: invoke the bundled javaw.exe with -jar Suwayomi-Launcher.jar ──
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
let sidecar = resource_dir.join("suwayomi-server-x86_64-pc-windows-msvc.exe");
|
||||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
let bundle_dir = resource_dir.join("suwayomi-bundle");
|
||||||
|
let jar = bundle_dir.join("Suwayomi-Launcher.jar");
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir));
|
if sidecar.exists() && jar.exists() {
|
||||||
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")]
|
|
||||||
{
|
|
||||||
// Tauri places externalBin sidecars next to the main binary in
|
|
||||||
// Contents/MacOS/, not in Contents/Resources/. Derive that path
|
|
||||||
// from resource_dir (Contents/Resources → Contents/MacOS).
|
|
||||||
let macos_dir = resource_dir.join("../MacOS")
|
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| resource_dir.join("../MacOS"));
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
|
|
||||||
|
|
||||||
// Tauri strips the target triple when installing externalBin sidecars
|
|
||||||
// into Contents/MacOS/, so the binary is always just "suwayomi-server"
|
|
||||||
// at runtime. The triple-suffixed names are only needed on disk at
|
|
||||||
// build time for Tauri to pick the right arch during bundling.
|
|
||||||
let candidates = [
|
|
||||||
"suwayomi-server",
|
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Search MacOS/ first (correct location), then Resources/ as fallback
|
|
||||||
// for flat dev layouts where the script sits next to resources.
|
|
||||||
for search_dir in &[&macos_dir, &resource_dir] {
|
|
||||||
for name in &candidates {
|
|
||||||
let p = search_dir.join(name);
|
|
||||||
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
|
||||||
if p.exists() {
|
|
||||||
do_log(log, &format!("[resolve] using macOS sidecar: {:?}", 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 {
|
return Ok(ServerInvocation {
|
||||||
bin: name.to_string(),
|
bin: sidecar.into_os_string(),
|
||||||
args: vec![],
|
prefix_args: vec![
|
||||||
|
"-jar".to_string(),
|
||||||
|
jar.to_string_lossy().into_owned(),
|
||||||
|
],
|
||||||
|
working_dir: Some(bundle_dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── macOS / Linux: sidecar script is self-contained ──
|
||||||
|
let candidates = [
|
||||||
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
|
// plain name as a dev/Linux fallback
|
||||||
|
"suwayomi-server",
|
||||||
|
];
|
||||||
|
|
||||||
|
for name in &candidates {
|
||||||
|
let p = resource_dir.join(name);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.into_os_string(),
|
||||||
|
prefix_args: vec![],
|
||||||
working_dir: None,
|
working_dir: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do_log(log, "[resolve] FAILED — no binary found anywhere");
|
Err("Suwayomi server binary not found. Please set the path in Settings.".to_string())
|
||||||
Err(SpawnError::NotConfigured(
|
|
||||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
{
|
{
|
||||||
let state = app.state::<ServerState>();
|
let guard = state.0.lock().unwrap();
|
||||||
if state.0.lock().unwrap().is_some() {
|
if guard.is_some() {
|
||||||
|
println!("Server already running, skipping spawn.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed server.conf before launching so Suwayomi starts in headless mode.
|
||||||
let data_dir = suwayomi_data_dir();
|
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);
|
seed_server_conf(&data_dir);
|
||||||
do_log(&mut log, "[spawn_server] server.conf seeded");
|
|
||||||
|
|
||||||
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) {
|
let invocation = resolve_server_binary(&binary, &app)?;
|
||||||
Ok(i) => i,
|
let shell = app.shell();
|
||||||
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!(
|
let rootdir_flag = format!(
|
||||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||||
data_dir.to_string_lossy()
|
data_dir.to_string_lossy()
|
||||||
);
|
);
|
||||||
|
|
||||||
invocation.args.insert(0, rootdir_flag);
|
// Build the full arg list: prefix_args (e.g. -jar foo.jar) + rootDir flag.
|
||||||
|
let args: Vec<String> = invocation.prefix_args.into_iter().chain(std::iter::once(rootdir_flag)).collect();
|
||||||
|
|
||||||
let working_dir = invocation.working_dir
|
// On Windows, set the working directory to the bundle folder so javaw.exe
|
||||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
// can resolve the JRE and jar relative paths correctly.
|
||||||
|
let cmd = shell
|
||||||
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)
|
.command(&invocation.bin)
|
||||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||||
.args(&invocation.args)
|
.args(&args)
|
||||||
.current_dir(&working_dir);
|
.current_dir(invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()));
|
||||||
|
|
||||||
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
|
|
||||||
|
|
||||||
match cmd.spawn() {
|
match cmd.spawn() {
|
||||||
Ok((_rx, child)) => {
|
Ok((_rx, child)) => {
|
||||||
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
|
println!("Spawned server: {:?}", invocation.bin);
|
||||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
let mut guard = state.0.lock().unwrap();
|
||||||
|
*guard = Some(child);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e));
|
eprintln!("Failed to spawn {:?}: {}", invocation.bin, e);
|
||||||
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
|
Err(e.to_string())
|
||||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
kill_tachidesk(&app);
|
kill_tachidesk(&app);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Update commands ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Fetch the list of all GitHub releases so the frontend can show a version picker.
|
|
||||||
/// Uses tauri-plugin-http so it goes through Tauri's permission system.
|
|
||||||
#[tauri::command]
|
|
||||||
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
|
||||||
use tauri_plugin_http::reqwest;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.user_agent("Moku")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("GitHub API returned {}", resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct GhRelease {
|
|
||||||
tag_name: String,
|
|
||||||
name: Option<String>,
|
|
||||||
body: Option<String>,
|
|
||||||
published_at: Option<String>,
|
|
||||||
html_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
|
||||||
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(releases
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| ReleaseInfo {
|
|
||||||
tag_name: r.tag_name.clone(),
|
|
||||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
|
||||||
body: r.body.unwrap_or_default(),
|
|
||||||
published_at: r.published_at.unwrap_or_default(),
|
|
||||||
html_url: r.html_url,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download and install the latest update using tauri-plugin-updater.
|
|
||||||
/// Emits `update-progress` events with `{ downloaded, total }` while downloading.
|
|
||||||
/// On Windows the installer runs in passive (silent) mode; the frontend prompts restart.
|
|
||||||
/// On other platforms this command is a no-op — the frontend opens the GitHub page instead.
|
|
||||||
#[tauri::command]
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
|
||||||
|
|
||||||
let updater = app.updater().map_err(|e| e.to_string())?;
|
|
||||||
let update = updater.check().await.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let Some(update) = update else {
|
|
||||||
return Err("No update available from the updater endpoint.".into());
|
|
||||||
};
|
|
||||||
|
|
||||||
let app_clone = app.clone();
|
|
||||||
update
|
|
||||||
.download_and_install(
|
|
||||||
move |downloaded, total| {
|
|
||||||
let _ = app_clone.emit("update-progress", UpdateProgress { downloaded: downloaded as u64, total });
|
|
||||||
},
|
|
||||||
|| {},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restart the app after a successful update install.
|
|
||||||
#[tauri::command]
|
|
||||||
fn restart_app(app: tauri::AppHandle) {
|
|
||||||
tauri::process::restart(&app.env());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── App entry point ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_os::init())
|
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
|
||||||
.plugin(tauri_plugin_process::init())
|
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_storage_info,
|
get_storage_info,
|
||||||
spawn_server,
|
spawn_server,
|
||||||
kill_server,
|
kill_server,
|
||||||
get_platform_ui_scale,
|
get_scale_factor,
|
||||||
list_releases,
|
|
||||||
download_and_install_update,
|
|
||||||
restart_app,
|
|
||||||
])
|
])
|
||||||
.setup(|_app| Ok(()))
|
.setup(|_app| Ok(()))
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.5.0",
|
"version": "0.3.0",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "dev.moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -17,8 +17,7 @@
|
|||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"decorations": false,
|
"decorations": false
|
||||||
"center": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
@@ -27,32 +26,18 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": [
|
"targets": ["appimage"],
|
||||||
"nsis"
|
|
||||||
],
|
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico",
|
"icons/icon.ico"
|
||||||
"icons/icon.png"
|
]
|
||||||
],
|
|
||||||
"externalBin": [],
|
|
||||||
"windows": {
|
|
||||||
"nsis": {
|
|
||||||
"installerIcon": "icons/icon.ico",
|
|
||||||
"installMode": "currentUser"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
},
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
|
||||||
"endpoints": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,8 +9,5 @@
|
|||||||
"devtools": true
|
"devtools": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"externalBin": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"app": {
|
|
||||||
"windows": [
|
|
||||||
{
|
|
||||||
"decorations": true,
|
|
||||||
"titleBarStyle": "Overlay",
|
|
||||||
"hiddenTitle": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"targets": ["dmg"],
|
|
||||||
"externalBin": [
|
|
||||||
"binaries/suwayomi-server"
|
|
||||||
],
|
|
||||||
"resources": {
|
|
||||||
"binaries/suwayomi-bundle": "suwayomi-bundle"
|
|
||||||
},
|
|
||||||
"macOS": {
|
|
||||||
"minimumSystemVersion": "11.0",
|
|
||||||
"exceptionDomain": "localhost",
|
|
||||||
"frameworks": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"bundle": {
|
|
||||||
"createUpdaterArtifacts": true,
|
|
||||||
"resources": [
|
|
||||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
|
||||||
"binaries/suwayomi-bundle/jre/**/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
|
||||||
"endpoints": [
|
|
||||||
"https://github.com/Youwes09/Moku/releases/latest/download/latest.json"
|
|
||||||
],
|
|
||||||
"windows": {
|
|
||||||
"installMode": "passive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { gql } from "./lib/client";
|
|
||||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
|
||||||
import { store, addToast, setActiveDownloads, setSettingsOpen } 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 ThemeEditor from "./components/settings/ThemeEditor.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";
|
|
||||||
|
|
||||||
let themeStyleEl: HTMLStyleElement | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const themeId = store.settings.theme ?? "dark";
|
|
||||||
const isCustom = themeId.startsWith("custom:");
|
|
||||||
|
|
||||||
if (!isCustom) {
|
|
||||||
themeStyleEl?.remove();
|
|
||||||
themeStyleEl = null;
|
|
||||||
document.documentElement.setAttribute("data-theme", themeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const custom = store.settings.customThemes?.find(t => t.id === themeId);
|
|
||||||
if (!custom) {
|
|
||||||
themeStyleEl?.remove();
|
|
||||||
themeStyleEl = null;
|
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vars = Object.entries(custom.tokens)
|
|
||||||
.map(([k, v]) => ` --${k}: ${v};`)
|
|
||||||
.join("\n");
|
|
||||||
const css = `[data-theme="custom"] {\n${vars}\n}`;
|
|
||||||
|
|
||||||
if (!themeStyleEl) {
|
|
||||||
themeStyleEl = document.createElement("style");
|
|
||||||
themeStyleEl.id = "moku-custom-theme";
|
|
||||||
document.head.appendChild(themeStyleEl);
|
|
||||||
}
|
|
||||||
themeStyleEl.textContent = css;
|
|
||||||
document.documentElement.setAttribute("data-theme", "custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
let themeEditorOpen = $state(false);
|
|
||||||
let themeEditorEditId = $state<string | null>(null);
|
|
||||||
|
|
||||||
function openThemeEditor(id?: string | null) {
|
|
||||||
themeEditorEditId = id ?? null;
|
|
||||||
themeEditorOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeThemeEditor() {
|
|
||||||
themeEditorOpen = false;
|
|
||||||
themeEditorEditId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 10;
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
|
|
||||||
let serverProbeOk = $state(false);
|
|
||||||
let appReady = $state(false);
|
|
||||||
let failed = $state(false);
|
|
||||||
let notConfigured = $state(false);
|
|
||||||
let idle = $state(false);
|
|
||||||
let devSplash = $state(false);
|
|
||||||
let 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 (idleTimer) clearTimeout(idleTimer);
|
|
||||||
if (idle) return;
|
|
||||||
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(() => {
|
|
||||||
store.settings.uiScale; platformScale;
|
|
||||||
applyZoom();
|
|
||||||
});
|
|
||||||
|
|
||||||
$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);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function checkForUpdateSilently() {
|
|
||||||
try {
|
|
||||||
const [currentVersion, releases] = await Promise.all([
|
|
||||||
getVersion(),
|
|
||||||
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
|
||||||
if (!valid.length) return;
|
|
||||||
|
|
||||||
const parse = (tag: string): number[] =>
|
|
||||||
tag.replace(/^v/, "").split(".").map(Number);
|
|
||||||
|
|
||||||
const compare = (a: number[], b: number[]): number => {
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const latestTag = valid
|
|
||||||
.map(r => r.tag_name)
|
|
||||||
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
|
||||||
.replace(/^v/, "");
|
|
||||||
|
|
||||||
const isNewer = compare(parse(latestTag), parse(currentVersion)) < 0;
|
|
||||||
if (isNewer) {
|
|
||||||
addToast({
|
|
||||||
kind: "info",
|
|
||||||
title: `Update available — v${latestTag}`,
|
|
||||||
body: "Open Settings → About to install.",
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelProbe = false;
|
|
||||||
|
|
||||||
function startProbe() {
|
|
||||||
cancelProbe = false;
|
|
||||||
failed = false;
|
|
||||||
let tries = 0;
|
|
||||||
|
|
||||||
async function probe() {
|
|
||||||
if (cancelProbe) return;
|
|
||||||
tries++;
|
|
||||||
try {
|
|
||||||
const rawUrl = store.settings.serverUrl;
|
|
||||||
const base = typeof rawUrl === "string" && rawUrl.trim()
|
|
||||||
? rawUrl.replace(/\/$/, "")
|
|
||||||
: "http://127.0.0.1:4567";
|
|
||||||
const s = store.settings;
|
|
||||||
const auth: Record<string, string> = s.serverAuthEnabled && s.serverAuthUser && s.serverAuthPass
|
|
||||||
? { Authorization: `Basic ${btoa(`${s.serverAuthUser.trim()}:${s.serverAuthPass.trim()}`)}` }
|
|
||||||
: {};
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", ...auth },
|
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
});
|
|
||||||
if (res.ok && !cancelProbe) { serverProbeOk = true; return; }
|
|
||||||
} catch {}
|
|
||||||
if (tries >= MAX_ATTEMPTS && !cancelProbe) { failed = true; return; }
|
|
||||||
if (!cancelProbe) setTimeout(probe, 750);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(probe, 800);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
|
||||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
|
||||||
|
|
||||||
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1);
|
|
||||||
applyZoom();
|
|
||||||
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
|
||||||
const unlistenResize = await win.onResized(async () => {
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startProbe();
|
|
||||||
|
|
||||||
type P = { chapterId: number; mangaId: number; progress: number }[];
|
|
||||||
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelProbe = true;
|
|
||||||
unlistenResize();
|
|
||||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
|
||||||
unlistenDownload?.();
|
|
||||||
delete (window as any).__mokuShowSplash;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
const timer = setTimeout(checkForUpdateSilently, 5_000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleRetry() {
|
|
||||||
failed = false;
|
|
||||||
notConfigured = false;
|
|
||||||
serverProbeOk = false;
|
|
||||||
startProbe();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBypass() {
|
|
||||||
cancelProbe = true;
|
|
||||||
serverProbeOk = true;
|
|
||||||
appReady = true;
|
|
||||||
}
|
|
||||||
</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}
|
|
||||||
onBypass={handleBypass} />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
{#if idle && !store.activeChapter}
|
|
||||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
|
||||||
onDismiss={() => { idle = false; resetIdle(); }} />
|
|
||||||
{/if}
|
|
||||||
{#if !store.activeChapter && !store.isFullscreen}<TitleBar />{/if}
|
|
||||||
<div class="content">
|
|
||||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
|
||||||
{#if themeEditorOpen}
|
|
||||||
<ThemeEditor
|
|
||||||
bind:editingId={themeEditorEditId}
|
|
||||||
onClose={closeThemeEditor}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<MangaPreview />
|
|
||||||
<Toaster />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
|
||||||
.content { flex: 1; overflow: hidden; }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { gql } from "./lib/client";
|
||||||
|
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||||
|
import "./styles/global.css";
|
||||||
|
import { useStore } from "./store";
|
||||||
|
import Layout from "./components/layout/Layout";
|
||||||
|
import Reader from "./components/pages/Reader";
|
||||||
|
import Settings from "./components/settings/Settings";
|
||||||
|
import MangaPreview from "./components/explore/MangaPreview";
|
||||||
|
import TitleBar from "./components/layout/TitleBar";
|
||||||
|
import Toaster from "./components/layout/Toaster";
|
||||||
|
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
|
||||||
|
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||||
|
import s from "./App.module.css";
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 30;
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const activeChapter = useStore((s) => s.activeChapter);
|
||||||
|
const settingsOpen = useStore((s) => s.settingsOpen);
|
||||||
|
const settings = useStore((s) => s.settings);
|
||||||
|
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||||
|
const addToast = useStore((s) => s.addToast);
|
||||||
|
|
||||||
|
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
|
||||||
|
const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
|
||||||
|
// appReady = ring filled + transition done, show main UI
|
||||||
|
const [appReady, setAppReady] = useState(!settings.autoStartServer);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [retryKey, setRetryKey] = useState(0);
|
||||||
|
const [idle, setIdle] = useState(false);
|
||||||
|
// dev tools: force show splash
|
||||||
|
const [devSplash, setDevSplash] = useState(false);
|
||||||
|
|
||||||
|
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
|
||||||
|
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const idleRef = useRef(false);
|
||||||
|
|
||||||
|
// expose devSplash trigger via window for settings
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).__mokuShowSplash = () => setDevSplash(true);
|
||||||
|
return () => { delete (window as any).__mokuShowSplash; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep idleRef in sync so resetIdle can check it without a stale closure
|
||||||
|
useEffect(() => { idleRef.current = idle; }, [idle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appReady) return;
|
||||||
|
function resetIdle() {
|
||||||
|
// While the idle splash is visible, don't reset — let SplashScreen's own
|
||||||
|
// dismiss flow handle teardown so the exit animation plays fully.
|
||||||
|
if (idleRef.current) return;
|
||||||
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||||
|
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||||
|
if (idleTimeoutMs === 0) return;
|
||||||
|
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
|
||||||
|
}
|
||||||
|
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
|
||||||
|
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
|
||||||
|
resetIdle();
|
||||||
|
return () => {
|
||||||
|
events.forEach(e => window.removeEventListener(e, resetIdle));
|
||||||
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [appReady, settings.idleTimeoutMin]);
|
||||||
|
|
||||||
|
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
||||||
|
for (const item of prev) {
|
||||||
|
if (item.state !== "DOWNLOADING") continue;
|
||||||
|
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
||||||
|
const manga = item.chapter.manga;
|
||||||
|
addToast({ kind:"success", title:"Chapter downloaded",
|
||||||
|
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
||||||
|
duration: 4000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyQueue(next: DownloadQueueItem[]) {
|
||||||
|
detectCompletions(prevQueueRef.current, next);
|
||||||
|
prevQueueRef.current = next;
|
||||||
|
setActiveDownloads(next.map(item => ({
|
||||||
|
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
||||||
|
}, [settings.uiScale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const theme = settings.theme ?? "dark";
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
}, [settings.theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const p = (e: MouseEvent) => e.preventDefault();
|
||||||
|
document.addEventListener("contextmenu", p);
|
||||||
|
return () => document.removeEventListener("contextmenu", p);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings.autoStartServer) return;
|
||||||
|
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
|
||||||
|
console.warn("Could not start server:", err));
|
||||||
|
return () => { invoke("kill_server").catch(() => {}); };
|
||||||
|
}, [settings.autoStartServer, settings.serverBinary]);
|
||||||
|
|
||||||
|
// Poll until server responds
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverProbeOk) return;
|
||||||
|
let cancelled = false, tries = 0;
|
||||||
|
async function probe() {
|
||||||
|
if (cancelled) return;
|
||||||
|
tries++;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
|
||||||
|
method:"POST", headers:{"Content-Type":"application/json"},
|
||||||
|
body: JSON.stringify({ query:"{ __typename }" }),
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
|
||||||
|
} catch {}
|
||||||
|
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
|
||||||
|
if (!cancelled) setTimeout(probe, 800);
|
||||||
|
}
|
||||||
|
const t = setTimeout(probe, 800);
|
||||||
|
return () => { cancelled = true; clearTimeout(t); };
|
||||||
|
}, [serverProbeOk, settings.serverUrl, retryKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appReady) return;
|
||||||
|
function poll() {
|
||||||
|
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
|
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
const id = setInterval(poll, 2000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [appReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
type P = { chapterId:number; mangaId:number; progress:number }[];
|
||||||
|
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
|
||||||
|
return () => { unsub.then(fn => fn()); };
|
||||||
|
}, [setActiveDownloads]);
|
||||||
|
|
||||||
|
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
|
||||||
|
if (devSplash) {
|
||||||
|
return (
|
||||||
|
<SplashScreen
|
||||||
|
mode="idle"
|
||||||
|
showFps
|
||||||
|
showCards={settings.splashCards ?? true}
|
||||||
|
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading splash — shown until ring fills + transition completes
|
||||||
|
if (!appReady) {
|
||||||
|
return (
|
||||||
|
<SplashScreen
|
||||||
|
mode="loading"
|
||||||
|
ringFull={serverProbeOk}
|
||||||
|
failed={failed}
|
||||||
|
showCards={settings.splashCards ?? true}
|
||||||
|
onReady={() => setAppReady(true)}
|
||||||
|
onRetry={() => {
|
||||||
|
setFailed(false);
|
||||||
|
setServerProbeOk(false);
|
||||||
|
setRetryKey(k => k+1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.root}>
|
||||||
|
{idle && !activeChapter && (
|
||||||
|
<SplashScreen
|
||||||
|
mode="idle"
|
||||||
|
showCards={settings.splashCards ?? true}
|
||||||
|
onDismiss={() => { setTimeout(() => { setIdle(false); }, SPLASH_EXIT_MS + 20); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!activeChapter && <TitleBar/>}
|
||||||
|
<div className={s.content}>
|
||||||
|
{activeChapter ? <Reader/> : <Layout/>}
|
||||||
|
</div>
|
||||||
|
{settingsOpen && <Settings/>}
|
||||||
|
<MangaPreview/>
|
||||||
|
<Toaster/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 27 KiB |
@@ -1,21 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,21 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,22 +1,27 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
<?xml version="1.0" standalone="no"?>
|
||||||
<rect width="512" height="512" rx="112" ry="112" fill="#091209"/>
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 500.000000 500.000000"
|
||||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
preserveAspectRatio="xMidYMid meet">
|
||||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)"
|
||||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
fill="#2d7a5f" stroke="none">
|
||||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
</g>
|
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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,83 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
.root {
|
||||||
|
padding: var(--sp-6);
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--sp-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerActions { display: flex; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
/* Loading state — accent tint so it's visually distinct */
|
||||||
|
.iconBtnLoading {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
.iconBtnLoading:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDotActive {
|
||||||
|
background: var(--accent);
|
||||||
|
animation: pulse 1.6s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusText {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex: 1;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusCount {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: border-color var(--t-fast), opacity var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActive { border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
/* Fade out rows being removed */
|
||||||
|
.rowRemoving { opacity: 0.4; pointer-events: none; }
|
||||||
|
|
||||||
|
/* Thumbnail */
|
||||||
|
.thumb {
|
||||||
|
width: 36px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info block */
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mangaTitle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapterName {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagesLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressWrap {
|
||||||
|
height: 2px;
|
||||||
|
background: var(--border-base);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right side */
|
||||||
|
.rowRight {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
.removeBtn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 160px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import {
|
||||||
|
GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER,
|
||||||
|
CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD,
|
||||||
|
} from "../../lib/queries";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import type { DownloadStatus } from "../../lib/types";
|
||||||
|
import s from "./DownloadQueue.module.css";
|
||||||
|
|
||||||
|
export default function DownloadQueue() {
|
||||||
|
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [togglingPlay, setTogglingPlay] = useState(false);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
|
||||||
|
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||||
|
|
||||||
|
// Apply status to local state + global store.
|
||||||
|
// Completion toasting is handled globally in App.tsx — no duplication here.
|
||||||
|
const applyStatus = useCallback((ds: DownloadStatus) => {
|
||||||
|
setStatus(ds);
|
||||||
|
setActiveDownloads(
|
||||||
|
ds.queue.map((item) => ({
|
||||||
|
chapterId: item.chapter.id,
|
||||||
|
mangaId: item.chapter.mangaId,
|
||||||
|
progress: item.progress,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [setActiveDownloads]);
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
|
.then((d) => applyStatus(d.downloadStatus))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
poll();
|
||||||
|
const id = setInterval(poll, 2000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function togglePlay() {
|
||||||
|
if (togglingPlay) return;
|
||||||
|
setTogglingPlay(true);
|
||||||
|
const wasRunning = status?.state === "STARTED";
|
||||||
|
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
|
||||||
|
try {
|
||||||
|
if (wasRunning) {
|
||||||
|
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
||||||
|
applyStatus(d.stopDownloader.downloadStatus);
|
||||||
|
} else {
|
||||||
|
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
||||||
|
applyStatus(d.startDownloader.downloadStatus);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
poll();
|
||||||
|
} finally {
|
||||||
|
setTogglingPlay(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear() {
|
||||||
|
if (clearing) return;
|
||||||
|
setClearing(true);
|
||||||
|
setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
|
||||||
|
setActiveDownloads([]);
|
||||||
|
try {
|
||||||
|
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||||
|
applyStatus(d.clearDownloader.downloadStatus);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
poll();
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dequeue(chapterId: number) {
|
||||||
|
if (dequeueing.has(chapterId)) return;
|
||||||
|
setDequeueing((prev) => new Set(prev).add(chapterId));
|
||||||
|
setStatus((prev) =>
|
||||||
|
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||||
|
poll();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
poll();
|
||||||
|
} finally {
|
||||||
|
setDequeueing((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(chapterId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = status?.queue ?? [];
|
||||||
|
const isRunning = status?.state === "STARTED";
|
||||||
|
|
||||||
|
function pagesDownloaded(progress: number, pageCount: number): number {
|
||||||
|
return Math.round(progress * pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.root}>
|
||||||
|
<div className={s.header}>
|
||||||
|
<h1 className={s.heading}>Downloads</h1>
|
||||||
|
<div className={s.headerActions}>
|
||||||
|
<button
|
||||||
|
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||||
|
onClick={togglePlay}
|
||||||
|
disabled={togglingPlay || (queue.length === 0 && !isRunning)}
|
||||||
|
title={isRunning ? "Pause" : "Resume"}
|
||||||
|
>
|
||||||
|
{togglingPlay ? (
|
||||||
|
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||||
|
) : isRunning ? (
|
||||||
|
<Pause size={14} weight="fill" />
|
||||||
|
) : (
|
||||||
|
<Play size={14} weight="fill" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||||
|
onClick={clear}
|
||||||
|
disabled={clearing || queue.length === 0}
|
||||||
|
title="Clear queue"
|
||||||
|
>
|
||||||
|
{clearing ? (
|
||||||
|
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash size={14} weight="regular" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.statusBar}>
|
||||||
|
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
||||||
|
<span className={s.statusText}>
|
||||||
|
{togglingPlay
|
||||||
|
? (isRunning ? "Pausing…" : "Starting…")
|
||||||
|
: isRunning ? "Downloading" : "Paused"}
|
||||||
|
</span>
|
||||||
|
<span className={s.statusCount}>{queue.length} queued</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
</div>
|
||||||
|
) : queue.length === 0 ? (
|
||||||
|
<div className={s.empty}>Queue is empty.</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.list}>
|
||||||
|
{queue.map((item, i) => {
|
||||||
|
const isActive = i === 0 && isRunning;
|
||||||
|
const pages = item.chapter.pageCount ?? 0;
|
||||||
|
const done = pagesDownloaded(item.progress, pages);
|
||||||
|
const manga = item.chapter.manga;
|
||||||
|
const isRemoving = dequeueing.has(item.chapter.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.chapter.id}
|
||||||
|
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
|
||||||
|
>
|
||||||
|
{manga?.thumbnailUrl && (
|
||||||
|
<div className={s.thumb}>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(manga.thumbnailUrl)}
|
||||||
|
alt={manga.title}
|
||||||
|
className={s.thumbImg}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={s.info}>
|
||||||
|
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
|
||||||
|
<span className={s.chapterName}>{item.chapter.name}</span>
|
||||||
|
{pages > 0 && (
|
||||||
|
<span className={s.pagesLabel}>
|
||||||
|
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<div className={s.progressWrap}>
|
||||||
|
<div
|
||||||
|
className={s.progressBar}
|
||||||
|
style={{ width: `${Math.round(item.progress * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.rowRight}>
|
||||||
|
<span className={s.stateLabel}>{item.state}</span>
|
||||||
|
{!isActive && (
|
||||||
|
<button
|
||||||
|
className={s.removeBtn}
|
||||||
|
onClick={() => dequeue(item.chapter.id)}
|
||||||
|
disabled={isRemoving}
|
||||||
|
title="Remove from queue"
|
||||||
|
>
|
||||||
|
{isRemoving
|
||||||
|
? <CircleNotch size={11} weight="light" className="anim-spin" />
|
||||||
|
: <X size={12} weight="light" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header / Tab switcher ───────────────────────────────────────────────── */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-base), color var(--t-base);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* Source picker */
|
||||||
|
.sourcePicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourcePickerLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceSelect {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceSelect:focus { border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--sp-5) 0 var(--sp-6);
|
||||||
|
will-change: scroll-position;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section ─────────────────────────────────────────────────────────────── */
|
||||||
|
.section {
|
||||||
|
margin-bottom: var(--sp-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--sp-6) var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seeAll {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seeAll:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Horizontal scroll row ───────────────────────────────────────────────── */
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 0 var(--sp-6);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* ── Card (shared by all rows) ───────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 110px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .cover { filter: brightness(1.06); }
|
||||||
|
.card:hover .title { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.coverWrap {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
will-change: filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inLibraryBadge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--sp-1);
|
||||||
|
left: var(--sp-1);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressFill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-fg);
|
||||||
|
border-radius: 0 2px 0 0;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 2px;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost card — invisible placeholder to fill row trailing space */
|
||||||
|
.ghostCard {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 110px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
||||||
|
.skeletonRow {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 0 var(--sp-6);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardSkeleton { flex-shrink: 0; width: 110px; }
|
||||||
|
|
||||||
|
.coverSkeleton {
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleSkeleton {
|
||||||
|
height: 11px;
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Genre drill-down grid ───────────────────────────────────────────────── */
|
||||||
|
.drillRoot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.drillTitle {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
padding: var(--sp-5) var(--sp-6);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
align-content: start;
|
||||||
|
will-change: scroll-position;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillCard {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillCard:hover .cover { filter: brightness(1.06); }
|
||||||
|
.drillCard:hover .title { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-8) var(--sp-6);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyHint {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── No source state ─────────────────────────────────────────────────────── */
|
||||||
|
.noSource {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
/* ── Explore More end-cap card ───────────────────────────────────────────── */
|
||||||
|
.exploreMoreCard {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 110px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.exploreMoreCard:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
|
||||||
|
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.exploreMoreInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploreMoreIcon {
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploreMoreLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploreMoreGenre {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.6;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
import { useEffect, useState, useMemo, useRef, memo } from "react";
|
||||||
|
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||||
|
import GenreDrillPage from "./GenreDrillPage";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { UPDATE_MANGA } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
||||||
|
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
|
||||||
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
|
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import type { Manga, Source } from "../../lib/types";
|
||||||
|
import SourceList from "../sources/SourceList";
|
||||||
|
import SourceBrowse from "../sources/SourceBrowse";
|
||||||
|
import s from "./Explore.module.css";
|
||||||
|
|
||||||
|
// ── Frecency score ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function frecencyScore(readAt: number, count: number): number {
|
||||||
|
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
||||||
|
return count / Math.log(hoursSince + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ghost / Skeleton ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
|
||||||
|
const GHOST_COUNT = 3;
|
||||||
|
const ROW_CAP = 25;
|
||||||
|
|
||||||
|
// Hijack vertical wheel delta → horizontal scroll on .row divs
|
||||||
|
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
|
||||||
|
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const canScrollLeft = el.scrollLeft > 0;
|
||||||
|
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
|
||||||
|
if (!canScrollLeft && !canScrollRight) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
el.scrollLeft += e.deltaY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={s.skeletonRow}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className={s.cardSkeleton}>
|
||||||
|
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||||
|
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cover image with fade-in ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src} alt={alt} className={className}
|
||||||
|
loading="lazy" decoding="async"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mini card ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MiniCard = memo(function MiniCard({
|
||||||
|
manga, onClick, onContextMenu, subtitle, progress,
|
||||||
|
}: {
|
||||||
|
manga: Manga;
|
||||||
|
onClick: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
|
subtitle?: string;
|
||||||
|
progress?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||||
|
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||||
|
{progress !== undefined && progress > 0 && (
|
||||||
|
<div className={s.progressBar}>
|
||||||
|
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={s.title}>{manga.title}</p>
|
||||||
|
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Explore More end-cap ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ExploreMoreCard = memo(function ExploreMoreCard({
|
||||||
|
genre, onClick,
|
||||||
|
}: { genre: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
|
||||||
|
<div className={s.exploreMoreInner}>
|
||||||
|
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
|
||||||
|
<span className={s.exploreMoreLabel}>Explore more</span>
|
||||||
|
<span className={s.exploreMoreGenre}>{genre}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Section ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title, icon, onSeeAll, loading, children,
|
||||||
|
}: {
|
||||||
|
title: string; icon?: React.ReactNode; onSeeAll?: () => void;
|
||||||
|
loading?: boolean; children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={s.section}>
|
||||||
|
<div className={s.sectionHeader}>
|
||||||
|
<span className={s.sectionTitle}>
|
||||||
|
<span className={s.sectionTitleIcon}>{icon}{title}</span>
|
||||||
|
</span>
|
||||||
|
{onSeeAll && (
|
||||||
|
<button className={s.seeAll} onClick={onSeeAll}>
|
||||||
|
See all <ArrowRight size={11} weight="light" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loading ? <SkeletonRow /> : children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ExploreMode = "explore" | "sources";
|
||||||
|
|
||||||
|
export default function Explore() {
|
||||||
|
const [mode, setMode] = useState<ExploreMode>("explore");
|
||||||
|
const activeSource = useStore((s) => s.activeSource);
|
||||||
|
const genreFilter = useStore((s) => s.genreFilter);
|
||||||
|
|
||||||
|
if (activeSource) return <SourceBrowse />;
|
||||||
|
if (genreFilter) return <GenreDrillPage />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.root}>
|
||||||
|
<div className={s.header}>
|
||||||
|
<div className={s.headerLeft}>
|
||||||
|
<h1 className={s.heading}>Explore</h1>
|
||||||
|
<div className={s.tabs}>
|
||||||
|
<button
|
||||||
|
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => setMode("explore")}
|
||||||
|
>
|
||||||
|
<Compass size={11} weight="bold" /> Explore
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => setMode("sources")}
|
||||||
|
>
|
||||||
|
<List size={11} weight="bold" /> Sources
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Keep ExploreFeed always mounted so data survives tab switches */}
|
||||||
|
<div style={{ display: mode === "explore" ? "contents" : "none" }}><ExploreFeed /></div>
|
||||||
|
{mode === "sources" && <SourceList />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Explore feed ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
||||||
|
|
||||||
|
// Single query replacing GET_ALL_MANGA + GET_LIBRARY merge
|
||||||
|
const EXPLORE_ALL_MANGA = `
|
||||||
|
query ExploreAllManga {
|
||||||
|
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary genre status
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fast genre row query against the local DB
|
||||||
|
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 status
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function ExploreFeed() {
|
||||||
|
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||||
|
const [loadingLib, setLoadingLib] = useState(true);
|
||||||
|
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
||||||
|
const [loadingPopular, setLoadingPopular] = useState(true);
|
||||||
|
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||||
|
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||||
|
const [sources, setSources] = useState<Source[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const fetchedGenresRef = useRef<string>("");
|
||||||
|
|
||||||
|
const history = useStore((s) => s.history);
|
||||||
|
const settings = useStore((s) => s.settings);
|
||||||
|
const setPreviewManga = useStore((s) => s.setPreviewManga);
|
||||||
|
const setGenreFilter = useStore((s) => s.setGenreFilter);
|
||||||
|
const folders = useStore((s) => s.settings.folders);
|
||||||
|
const addFolder = useStore((s) => s.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((s) => s.assignMangaToFolder);
|
||||||
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { abortRef.current?.abort(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
|
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||||
|
disabled: m.inLibrary,
|
||||||
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
|
.then(() => { cache.clear(CACHE_KEYS.LIBRARY); })
|
||||||
|
.catch(console.error),
|
||||||
|
},
|
||||||
|
...(folders.length > 0 ? [
|
||||||
|
{ separator: true } as ContextMenuEntry,
|
||||||
|
...folders.map((f): ContextMenuEntry => ({
|
||||||
|
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||||
|
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||||
|
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||||
|
})),
|
||||||
|
] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "New folder & add",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data load ─────────────────────────────────────────────────────────────
|
||||||
|
// Library + genre rows: single local DB query each — instant, no source calls.
|
||||||
|
// Popular: still needs fetchSourceManga since there's no local equivalent.
|
||||||
|
useEffect(() => {
|
||||||
|
const alreadyLoaded = allManga.length > 0;
|
||||||
|
if (alreadyLoaded) return;
|
||||||
|
|
||||||
|
setLoadingLib(true);
|
||||||
|
setLoadingPopular(true);
|
||||||
|
setLoadError(false);
|
||||||
|
|
||||||
|
const preferredLang = settings.preferredExtensionLang || "en";
|
||||||
|
if (retryCount > 0) {
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
cache.clear(CACHE_KEYS.SOURCES);
|
||||||
|
fetchedGenresRef.current = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single query for all manga — library flag included
|
||||||
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA)
|
||||||
|
.then((d) => d.mangas.nodes)
|
||||||
|
).then(setAllManga)
|
||||||
|
.catch((e) => { console.error(e); setLoadError(true); })
|
||||||
|
.finally(() => setLoadingLib(false));
|
||||||
|
|
||||||
|
// Sources — only needed for Popular section
|
||||||
|
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
||||||
|
).then((allSources) => {
|
||||||
|
if (allSources.length === 0) { setLoadingPopular(false); return; }
|
||||||
|
const topSources = getTopSources(allSources).slice(0, 2);
|
||||||
|
setSources(allSources);
|
||||||
|
|
||||||
|
cache.get(CACHE_KEYS.POPULAR, () =>
|
||||||
|
Promise.allSettled(
|
||||||
|
topSources.map((src) =>
|
||||||
|
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
|
source: src.id, type: "POPULAR", page: 1, query: null,
|
||||||
|
}).then((d) => d.fetchSourceManga.mangas)
|
||||||
|
)
|
||||||
|
).then((results) => {
|
||||||
|
const merged: Manga[] = [];
|
||||||
|
for (const r of results)
|
||||||
|
if (r.status === "fulfilled") merged.push(...r.value);
|
||||||
|
return dedupeMangaByTitle(merged).slice(0, 30);
|
||||||
|
})
|
||||||
|
).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
|
||||||
|
}).catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [retryCount]);
|
||||||
|
|
||||||
|
// ── Frecency genres (derived from history + library) ──────────────────────
|
||||||
|
const frecencyGenres = useMemo(() => {
|
||||||
|
const mangaScores = new Map<number, number>();
|
||||||
|
const mangaReadAt = new Map<number, number>();
|
||||||
|
for (const entry of history) {
|
||||||
|
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
|
||||||
|
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
|
||||||
|
mangaReadAt.set(entry.mangaId, entry.readAt);
|
||||||
|
}
|
||||||
|
const genreWeights = new Map<string, number>();
|
||||||
|
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||||
|
for (const [mangaId, count] of mangaScores.entries()) {
|
||||||
|
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
||||||
|
for (const genre of mangaMap.get(mangaId)?.genre ?? [])
|
||||||
|
genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score);
|
||||||
|
}
|
||||||
|
if (genreWeights.size === 0)
|
||||||
|
allManga.filter((m) => m.inLibrary).forEach((m) =>
|
||||||
|
(m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
||||||
|
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
|
||||||
|
return Array.from(genreWeights.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(([g]) => g);
|
||||||
|
}, [allManga, history]);
|
||||||
|
|
||||||
|
// ── Genre rows: query local DB directly ─────────────────────────────────
|
||||||
|
// One query per genre against the local mangas table — instant, no source I/O.
|
||||||
|
useEffect(() => {
|
||||||
|
if (frecencyGenres.length === 0 || allManga.length === 0) return;
|
||||||
|
|
||||||
|
const genreKey = frecencyGenres.join(",");
|
||||||
|
if (fetchedGenresRef.current === genreKey) return;
|
||||||
|
fetchedGenresRef.current = genreKey;
|
||||||
|
|
||||||
|
setLoadingGenres(true);
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
const streamingMap = new Map<string, Manga[]>();
|
||||||
|
|
||||||
|
Promise.allSettled(
|
||||||
|
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;
|
||||||
|
streamingMap.set(genre, mangas);
|
||||||
|
setGenreResults(new Map(streamingMap));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||||
|
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
||||||
|
}, [frecencyGenres, allManga]);
|
||||||
|
|
||||||
|
function openManga(m: Manga) { setPreviewManga(m); }
|
||||||
|
|
||||||
|
// ── Continue reading ──────────────────────────────────────────────────────
|
||||||
|
const continueReading = useMemo(() => {
|
||||||
|
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
||||||
|
for (const entry of history) {
|
||||||
|
if (seen.has(entry.mangaId)) continue;
|
||||||
|
seen.add(entry.mangaId);
|
||||||
|
const manga = mangaMap.get(entry.mangaId);
|
||||||
|
if (!manga) continue;
|
||||||
|
result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 });
|
||||||
|
if (result.length >= 12) break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [history, allManga]);
|
||||||
|
|
||||||
|
// ── Recommended ───────────────────────────────────────────────────────────
|
||||||
|
const recommended = useMemo(() => {
|
||||||
|
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
|
||||||
|
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
||||||
|
return allManga
|
||||||
|
.filter((m) => m.inLibrary && !continueIds.has(m.id) &&
|
||||||
|
frecencyGenres.some((g) => (m.genre ?? []).includes(g)))
|
||||||
|
.slice(0, 20);
|
||||||
|
}, [allManga, frecencyGenres, continueReading]);
|
||||||
|
|
||||||
|
const genresLoading = loadingGenres;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.body}>
|
||||||
|
|
||||||
|
{(continueReading.length > 0 || loadingLib) && (
|
||||||
|
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
|
||||||
|
<div className={s.row} onWheel={handleRowWheel}>
|
||||||
|
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
|
||||||
|
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
|
||||||
|
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-cr-${i}`} />)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(recommended.length > 0 || loadingLib) && (
|
||||||
|
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
|
||||||
|
<div className={s.row} onWheel={handleRowWheel}>
|
||||||
|
{recommended.slice(0, ROW_CAP).map((m) => (
|
||||||
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(popularManga.length > 0 || loadingPopular) && (
|
||||||
|
<Section
|
||||||
|
title={sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
||||||
|
icon={<Fire size={11} weight="bold" />}
|
||||||
|
loading={loadingPopular}
|
||||||
|
>
|
||||||
|
{sources.length === 0 ? (
|
||||||
|
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.row} onWheel={handleRowWheel}>
|
||||||
|
{popularManga.slice(0, ROW_CAP).map((m) => (
|
||||||
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{frecencyGenres.map((genre) => {
|
||||||
|
const items = genreResults.get(genre) ?? [];
|
||||||
|
const isLoading = genresLoading && items.length === 0;
|
||||||
|
if (!isLoading && items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
|
||||||
|
<div className={s.row} onWheel={handleRowWheel}>
|
||||||
|
{items.slice(0, ROW_CAP).map((m) => (
|
||||||
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||||
|
))}
|
||||||
|
{items.length >= ROW_CAP && (
|
||||||
|
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
|
||||||
|
)}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!loadingLib && !loadingPopular && !loadingGenres &&
|
||||||
|
continueReading.length === 0 && recommended.length === 0 &&
|
||||||
|
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
||||||
|
<div className={s.empty}>
|
||||||
|
{loadError ? (
|
||||||
|
<>
|
||||||
|
<span>Could not reach Suwayomi</span>
|
||||||
|
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
|
||||||
|
<button
|
||||||
|
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||||
|
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Nothing to explore yet</span>
|
||||||
|
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.back:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingHint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid fills entire remaining height, no show-more needed */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
align-content: start;
|
||||||
|
/* Smooth GPU-accelerated scrolling */
|
||||||
|
will-change: scroll-position;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.card:hover .cover { filter: brightness(1.06); }
|
||||||
|
.card:hover .cardTitle { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.coverWrap {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
/* Solid bg shown while image fades in — matches skeleton color */
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
will-change: filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inLibraryBadge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--sp-1);
|
||||||
|
left: var(--sp-1);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeletons */
|
||||||
|
.cardSkeleton { padding: 0; }
|
||||||
|
.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); }
|
||||||
|
.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultCount {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show more — spans full grid width */
|
||||||
|
.showMoreCell {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-2) 0 var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showMoreBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 7px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.showMoreBtn:hover:not(:disabled) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
.showMoreBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
|
||||||
|
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
|
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
|
import type { Manga, Source } from "../../lib/types";
|
||||||
|
import s from "./GenreDrillPage.module.css";
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const INITIAL_PAGES = 3;
|
||||||
|
const MAX_SOURCES = 12;
|
||||||
|
const CONCURRENCY = 4;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* genreFilter in the store is either a single tag ("Action") or a `+`-joined
|
||||||
|
* multi-tag string ("Action+Romance"). Parse it into an array.
|
||||||
|
*
|
||||||
|
* Callers set multi-tag filters via:
|
||||||
|
* setGenreFilter("Action+Romance")
|
||||||
|
*
|
||||||
|
* The Explore feed's "See all" button continues to pass single strings and
|
||||||
|
* requires no change.
|
||||||
|
*/
|
||||||
|
function parseTags(genreFilter: string): string[] {
|
||||||
|
return genreFilter.split("+").map((t) => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Action", "Action & Romance", "Action, Romance & Isekai" */
|
||||||
|
function tagsLabel(tags: string[]): string {
|
||||||
|
if (tags.length === 1) return tags[0];
|
||||||
|
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side AND filter.
|
||||||
|
* Sources only accept a single query string, so we send the first tag and
|
||||||
|
* drop results that don't also have the remaining tags in their genre list.
|
||||||
|
*/
|
||||||
|
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
||||||
|
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
|
||||||
|
return tags.every((t) => genres.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;
|
||||||
|
const item = items[i++];
|
||||||
|
await fn(item).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CoverImg ──────────────────────────────────────────────────────────────────
|
||||||
|
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src} alt={alt} className={className}
|
||||||
|
loading="lazy" decoding="async"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GenreDrillPage ────────────────────────────────────────────────────────────
|
||||||
|
export default function GenreDrillPage() {
|
||||||
|
const genreFilter = useStore((st) => st.genreFilter);
|
||||||
|
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||||
|
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||||
|
const settings = useStore((st) => st.settings);
|
||||||
|
const folders = useStore((st) => st.settings.folders);
|
||||||
|
const addFolder = useStore((st) => st.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||||
|
|
||||||
|
// Parse the filter string into individual tags
|
||||||
|
const tags = useMemo(() => parseTags(genreFilter), [genreFilter]);
|
||||||
|
// First tag is sent as the source query string (sources accept only one term)
|
||||||
|
const primaryTag = tags[0] ?? "";
|
||||||
|
|
||||||
|
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
||||||
|
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||||
|
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
|
||||||
|
// Per-source next-page tracker; -1 means exhausted
|
||||||
|
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||||
|
const sourcesRef = useRef<Source[]>([]);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// ── Initial load ─────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (tags.length === 0) return;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
setLoadingInitial(true);
|
||||||
|
setSourceManga([]);
|
||||||
|
setLibraryManga([]);
|
||||||
|
setVisibleCount(PAGE_SIZE);
|
||||||
|
nextPageRef.current = new Map();
|
||||||
|
|
||||||
|
const preferredLang = settings.preferredExtensionLang || "en";
|
||||||
|
|
||||||
|
// ── Library (local DB, instant) ───────────────────────────────────────
|
||||||
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
Promise.all([
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||||
|
]).then(([all, lib]) => {
|
||||||
|
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||||
|
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||||
|
|
||||||
|
// ── Sources: stream results as each source responds ───────────────────
|
||||||
|
// Source list is stable within a session — cache indefinitely.
|
||||||
|
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => dedupeSources(d.sources.nodes.filter((src) => src.id !== "0"), preferredLang)),
|
||||||
|
Infinity,
|
||||||
|
).then(async (allSources) => {
|
||||||
|
const sources = allSources.slice(0, MAX_SOURCES);
|
||||||
|
sourcesRef.current = sources;
|
||||||
|
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
||||||
|
|
||||||
|
await runConcurrent(sources, async (src) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
// PageSet tracks which pages we've already fetched for this (source, tags) bucket.
|
||||||
|
// On navigation-away → back the pages are still in the TTL store, so fetchPage
|
||||||
|
// returns the cached promise immediately without hitting the network.
|
||||||
|
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||||
|
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, 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((e: any) => {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || ctrl.signal.aborted) break;
|
||||||
|
|
||||||
|
ps.add(page);
|
||||||
|
|
||||||
|
// For multi-tag searches: client-side AND filter for tags beyond the first.
|
||||||
|
// Sources only support a single query string, so we send primaryTag and
|
||||||
|
// drop results that don't contain the remaining tags in their genre array.
|
||||||
|
const matching = tags.length > 1
|
||||||
|
? result.mangas.filter((m) => matchesAllTags(m, tags))
|
||||||
|
: result.mangas;
|
||||||
|
|
||||||
|
pageItems.push(...matching);
|
||||||
|
|
||||||
|
if (!result.hasNextPage) {
|
||||||
|
nextPageRef.current.set(src.id, -1);
|
||||||
|
break;
|
||||||
|
} else if (page === INITIAL_PAGES) {
|
||||||
|
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||||
|
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
||||||
|
setLoadingInitial(false);
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
|
||||||
|
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||||
|
}).catch((e) => {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { ctrl.abort(); };
|
||||||
|
// genreFilter (not tags) as the dep — tags is derived from it and would
|
||||||
|
// cause an extra render on every parse; genreFilter is the stable identity.
|
||||||
|
}, [genreFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Derived merged list ───────────────────────────────────────────────────
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
// For multi-tag: library results must match ALL tags
|
||||||
|
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
||||||
|
const libIds = new Set(libMatches.map((m) => m.id));
|
||||||
|
const srcOnly = sourceManga.filter((m) => !libIds.has(m.id));
|
||||||
|
return dedupeMangaById([...libMatches, ...srcOnly]);
|
||||||
|
}, [libraryManga, sourceManga, tags]);
|
||||||
|
|
||||||
|
// ── Load more ─────────────────────────────────────────────────────────────
|
||||||
|
const hasMoreVisible = visibleCount < filtered.length;
|
||||||
|
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||||
|
const hasMore = hasMoreVisible || hasMoreNetwork;
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (loadingMore) return;
|
||||||
|
|
||||||
|
// Fast path: buffered results already in memory
|
||||||
|
if (hasMoreVisible) {
|
||||||
|
setVisibleCount((v) => v + PAGE_SIZE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: fetch next pages from sources
|
||||||
|
const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||||
|
if (!sources.length) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runConcurrent(sources, async (src) => {
|
||||||
|
const page = nextPageRef.current.get(src.id)!;
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
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((e: any) => {
|
||||||
|
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
ps.add(page);
|
||||||
|
nextPageRef.current.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)
|
||||||
|
setSourceManga((prev) => dedupeMangaById([...prev, ...matching]));
|
||||||
|
}, ctrl.signal);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) {
|
||||||
|
setVisibleCount((v) => v + PAGE_SIZE);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loadingMore, hasMoreVisible, primaryTag, tags]);
|
||||||
|
|
||||||
|
// ── Context menu ──────────────────────────────────────────────────────────
|
||||||
|
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
|
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||||
|
disabled: m.inLibrary,
|
||||||
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
|
.then(() => {
|
||||||
|
setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
})
|
||||||
|
.catch(console.error),
|
||||||
|
},
|
||||||
|
...(folders.length > 0 ? [
|
||||||
|
{ separator: true } as ContextMenuEntry,
|
||||||
|
...folders.map((f): ContextMenuEntry => ({
|
||||||
|
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||||
|
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||||
|
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||||
|
})),
|
||||||
|
] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "New folder & add",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleItems = filtered.slice(0, visibleCount);
|
||||||
|
const label = tagsLabel(tags);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.root}>
|
||||||
|
<div className={s.header}>
|
||||||
|
<button className={s.back} onClick={() => setGenreFilter("")}>
|
||||||
|
<ArrowLeft size={13} weight="light" />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
<span className={s.title}>{label}</span>
|
||||||
|
{loadingInitial && filtered.length === 0 ? null : (
|
||||||
|
<span className={s.resultCount}>
|
||||||
|
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!loadingInitial && hasMoreNetwork && (
|
||||||
|
<span className={s.loadingHint}>More loading…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingInitial && filtered.length === 0 ? (
|
||||||
|
<div className={s.grid}>
|
||||||
|
{Array.from({ length: 50 }).map((_, i) => (
|
||||||
|
<div key={i} className={s.cardSkeleton}>
|
||||||
|
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||||
|
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className={s.empty}>No manga found for "{label}".</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.grid}>
|
||||||
|
{visibleItems.map((m) => (
|
||||||
|
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||||
|
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||||
|
</div>
|
||||||
|
<p className={s.cardTitle}>{m.title}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<div className={s.showMoreCell}>
|
||||||
|
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||||
|
{loadingMore
|
||||||
|
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display: "inline-block" }} /> Loading…</>
|
||||||
|
: "Show more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
/* ── Animations ──────────────────────────────────────────────────────────── */
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||||
|
|
||||||
|
/* ── Backdrop ────────────────────────────────────────────────────────────── */
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.72);
|
||||||
|
z-index: var(--z-settings);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: fadeIn 0.12s ease both;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal shell ─────────────────────────────────────────────────────────── */
|
||||||
|
.modal {
|
||||||
|
width: min(800px, calc(100vw - 48px));
|
||||||
|
height: min(560px, calc(100vh - 80px));
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: scaleIn 0.16s ease both;
|
||||||
|
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cover column ────────────────────────────────────────────────────────── */
|
||||||
|
.coverCol {
|
||||||
|
width: 190px; flex-shrink: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: var(--sp-5) var(--sp-4) var(--sp-4);
|
||||||
|
gap: var(--sp-3);
|
||||||
|
overflow-y: auto; overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.coverCol::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.coverWrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%; aspect-ratio: 2 / 3; object-fit: cover;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverSpinner {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverActions {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cover action buttons ────────────────────────────────────────────────── */
|
||||||
|
.actionBtn {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: var(--sp-2);
|
||||||
|
width: 100%; padding: 7px var(--sp-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: none; color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||||
|
.actionBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.actionBtnActive {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.actionBtnLabel {
|
||||||
|
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
||||||
|
.folderWrap { position: relative; width: 100%; }
|
||||||
|
|
||||||
|
.folderMenu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px); left: 0; right: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--sp-1);
|
||||||
|
display: flex; flex-direction: column; gap: 1px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
z-index: 10;
|
||||||
|
animation: scaleIn 0.1s ease both;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folderEmpty {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); padding: var(--sp-2) var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folderItem {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none; border: none; cursor: pointer; text-align: left;
|
||||||
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
.folderItemOn { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||||
|
|
||||||
|
.folderCreateRow {
|
||||||
|
display: flex; gap: var(--sp-1); padding: var(--sp-1);
|
||||||
|
}
|
||||||
|
.folderInput {
|
||||||
|
flex: 1; background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm); padding: 4px 8px;
|
||||||
|
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
outline: none; min-width: 0;
|
||||||
|
}
|
||||||
|
.folderInput:focus { border-color: var(--border-focus); }
|
||||||
|
|
||||||
|
.folderOkBtn {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
padding: 4px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.folderOkBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.folderNewBtn {
|
||||||
|
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); background: none; border: none;
|
||||||
|
cursor: pointer; text-align: left; width: 100%;
|
||||||
|
transition: color var(--t-fast);
|
||||||
|
}
|
||||||
|
.folderNewBtn:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Content column ──────────────────────────────────────────────────────── */
|
||||||
|
.content {
|
||||||
|
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||||
|
.contentHeader {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-lg); font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-primary); letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline {
|
||||||
|
font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skByline {
|
||||||
|
height: 14px; width: 55%;
|
||||||
|
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||||
|
animation: pulse 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint); border: none; background: none;
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||||
|
.contentBody {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: var(--sp-5) var(--sp-6);
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-4);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error banner ────────────────────────────────────────────────────────── */
|
||||||
|
.errorBanner {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--color-warn, #f59e0b);
|
||||||
|
background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent);
|
||||||
|
border-radius: var(--radius-sm); padding: 6px var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton rows ───────────────────────────────────────────────────────── */
|
||||||
|
.skRow {
|
||||||
|
display: flex; gap: var(--sp-2); align-items: center;
|
||||||
|
}
|
||||||
|
.skBadge {
|
||||||
|
height: 20px; width: 54px;
|
||||||
|
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||||
|
animation: pulse 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skDesc {
|
||||||
|
display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0;
|
||||||
|
}
|
||||||
|
.skLine {
|
||||||
|
height: 13px; background: var(--bg-overlay);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: pulse 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges ──────────────────────────────────────────────────────────────── */
|
||||||
|
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||||
|
padding: 3px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised); color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.badgeGreen {
|
||||||
|
background: color-mix(in srgb, #22c55e 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #22c55e 30%, transparent);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.badgeDim { /* default */ }
|
||||||
|
.badgeAccent {
|
||||||
|
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
.badgeUnread {
|
||||||
|
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #f59e0b 30%, transparent);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.badgeNsfw {
|
||||||
|
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chapter box — clearly separated from description ────────────────────── */
|
||||||
|
.chapterBox {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapterLoading {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.chapterLoadingLabel {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapterMeta {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapterLabel {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlAllBtn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 3px 10px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
.dlAllBtn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.progressTrack {
|
||||||
|
height: 3px; background: var(--bg-overlay);
|
||||||
|
border-radius: var(--radius-full); overflow: hidden;
|
||||||
|
}
|
||||||
|
.progressFill {
|
||||||
|
height: 100%; background: var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readBtn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: 8px var(--sp-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||||
|
cursor: pointer; align-self: flex-start;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
}
|
||||||
|
.readBtn:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
/* ── Description block ───────────────────────────────────────────────────── */
|
||||||
|
.descBlock {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||||
|
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
line-height: var(--leading-base);
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.descOpen {
|
||||||
|
display: block; -webkit-line-clamp: unset; overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descToggle {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); background: none; border: none;
|
||||||
|
cursor: pointer; padding: 0; align-self: flex-start;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.descToggle:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Genre tags ──────────────────────────────────────────────────────────── */
|
||||||
|
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.genreTag {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
||||||
|
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised); color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genreTagClickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.genreTagClickable:hover {
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata table ──────────────────────────────────────────────────────── */
|
||||||
|
.metaTable {
|
||||||
|
display: flex; flex-direction: column; gap: 1px;
|
||||||
|
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaRow {
|
||||||
|
display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0;
|
||||||
|
}
|
||||||
|
.metaKey {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase; min-width: 56px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.metaVal {
|
||||||
|
font-size: var(--text-sm); color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
.metaLink {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: var(--text-sm); color: var(--accent-fg);
|
||||||
|
text-decoration: none; transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.metaLink:hover { opacity: 0.75; }
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
X, BookmarkSimple, ArrowSquareOut, Play,
|
||||||
|
CircleNotch, Books, CaretDown, FolderSimplePlus, Folder,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import {
|
||||||
|
GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||||
|
} from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
|
import s from "./MangaPreview.module.css";
|
||||||
|
|
||||||
|
export default function MangaPreview() {
|
||||||
|
const previewManga = useStore((st) => st.previewManga);
|
||||||
|
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||||
|
const setActiveManga = useStore((st) => st.setActiveManga);
|
||||||
|
const setNavPage = useStore((st) => st.setNavPage);
|
||||||
|
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||||
|
const openReader = useStore((st) => st.openReader);
|
||||||
|
const addToast = useStore((st) => st.addToast);
|
||||||
|
const folders = useStore((st) => st.settings.folders);
|
||||||
|
const addFolder = useStore((st) => st.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||||
|
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
||||||
|
|
||||||
|
const [manga, setManga] = useState<Manga | null>(null);
|
||||||
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||||
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
const [loadingChapters, setLoadingChapters] = useState(false);
|
||||||
|
const [togglingLib, setTogglingLib] = useState(false);
|
||||||
|
const [descExpanded, setDescExpanded] = useState(false);
|
||||||
|
const [folderOpen, setFolderOpen] = useState(false);
|
||||||
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||||
|
const [queueingAll, setQueueingAll] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
const detailAbort = useRef<AbortController | null>(null);
|
||||||
|
const chapterAbort = useRef<AbortController | null>(null);
|
||||||
|
const folderRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
detailAbort.current?.abort();
|
||||||
|
chapterAbort.current?.abort();
|
||||||
|
setPreviewManga(null);
|
||||||
|
setManga(null);
|
||||||
|
setChapters([]);
|
||||||
|
setDescExpanded(false);
|
||||||
|
setFolderOpen(false);
|
||||||
|
setCreatingFolder(false);
|
||||||
|
setNewFolderName("");
|
||||||
|
setFetchError(null);
|
||||||
|
}, [setPreviewManga]);
|
||||||
|
|
||||||
|
// ── Fetch detail + chapters on open ──────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewManga) return;
|
||||||
|
|
||||||
|
// Abort any in-flight requests from previous manga
|
||||||
|
detailAbort.current?.abort();
|
||||||
|
chapterAbort.current?.abort();
|
||||||
|
|
||||||
|
const dCtrl = new AbortController();
|
||||||
|
const cCtrl = new AbortController();
|
||||||
|
detailAbort.current = dCtrl;
|
||||||
|
chapterAbort.current = cCtrl;
|
||||||
|
|
||||||
|
setManga(null);
|
||||||
|
setChapters([]);
|
||||||
|
setDescExpanded(false);
|
||||||
|
setFetchError(null);
|
||||||
|
setLoadingDetail(true);
|
||||||
|
setLoadingChapters(true);
|
||||||
|
|
||||||
|
const id = previewManga.id;
|
||||||
|
|
||||||
|
// ── Detail fetch strategy ─────────────────────────────────────────────
|
||||||
|
// For source/explore manga we must call FETCH_MANGA (mutation that
|
||||||
|
// hits the source and syncs to the local DB). GET_MANGA only works for
|
||||||
|
// manga already in the local DB with full metadata.
|
||||||
|
//
|
||||||
|
// Fast path: if we already cached a full record, use it directly.
|
||||||
|
// Slow path: always try FETCH_MANGA first — it never fails for valid IDs
|
||||||
|
// and returns the richest data. Fall back to GET_MANGA if it errors.
|
||||||
|
//
|
||||||
|
(async (): Promise<Manga> => {
|
||||||
|
const cacheKey = CACHE_KEYS.MANGA(id);
|
||||||
|
|
||||||
|
// Already have a cached rich record — no network needed
|
||||||
|
if (cache.has(cacheKey)) {
|
||||||
|
return cache.get(cacheKey, () =>
|
||||||
|
Promise.resolve(previewManga as Manga)
|
||||||
|
) as Promise<Manga>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try FETCH_MANGA first — works for all manga regardless of whether
|
||||||
|
// they are in the local DB yet (it fetches from source and syncs).
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchManga: { manga: Manga } }>(
|
||||||
|
FETCH_MANGA, { id }, dCtrl.signal
|
||||||
|
);
|
||||||
|
return d.fetchManga.manga;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") throw e;
|
||||||
|
// FETCH_MANGA failed (e.g. source offline) — fall back to local DB
|
||||||
|
const local = await gql<{ manga: Manga }>(
|
||||||
|
GET_MANGA, { id }, dCtrl.signal
|
||||||
|
).then((d) => d.manga);
|
||||||
|
if (local) return local;
|
||||||
|
throw new Error("Could not load manga details");
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.then((fullManga) => {
|
||||||
|
if (dCtrl.signal.aborted) return;
|
||||||
|
// Cache the rich record so re-opening is instant
|
||||||
|
if (!cache.has(CACHE_KEYS.MANGA(id))) {
|
||||||
|
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
||||||
|
}
|
||||||
|
setManga(fullManga);
|
||||||
|
setLoadingDetail(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
console.error("MangaPreview detail fetch:", e);
|
||||||
|
// Show whatever sparse data we have from previewManga
|
||||||
|
setManga(previewManga as Manga);
|
||||||
|
setFetchError("Could not load full details — showing cached data");
|
||||||
|
setLoadingDetail(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Chapter fetch — local DB first, fall back to source fetch ────────
|
||||||
|
gql<{ chapters: { nodes: Chapter[] } }>(
|
||||||
|
GET_CHAPTERS, { mangaId: id }, cCtrl.signal
|
||||||
|
)
|
||||||
|
.then(async (d) => {
|
||||||
|
if (cCtrl.signal.aborted) return;
|
||||||
|
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
// If no local chapters yet (explore/source manga), fetch from source
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
try {
|
||||||
|
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(
|
||||||
|
FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal
|
||||||
|
);
|
||||||
|
if (!cCtrl.signal.aborted)
|
||||||
|
nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
// Leave nodes empty — not a fatal error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cCtrl.signal.aborted) setChapters(nodes);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||||
|
.finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); });
|
||||||
|
|
||||||
|
return () => { dCtrl.abort(); cCtrl.abort(); };
|
||||||
|
}, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Keyboard close ────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewManga) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [previewManga, close]);
|
||||||
|
|
||||||
|
// ── Folder outside click ──────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!folderOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (folderRef.current && !folderRef.current.contains(e.target as Node)) {
|
||||||
|
setFolderOpen(false); setCreatingFolder(false); setNewFolderName("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [folderOpen]);
|
||||||
|
|
||||||
|
if (!previewManga) return null;
|
||||||
|
|
||||||
|
// Always show title/cover from previewManga immediately; upgrade to fetched manga when ready
|
||||||
|
const displayManga = manga ?? previewManga;
|
||||||
|
const totalCount = chapters.length;
|
||||||
|
const readCount = chapters.filter((c) => c.isRead).length;
|
||||||
|
const unreadCount = totalCount - readCount;
|
||||||
|
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
||||||
|
const bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
|
||||||
|
const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false;
|
||||||
|
|
||||||
|
// Scanlators — deduplicated, non-empty
|
||||||
|
const scanlators = [...new Set(
|
||||||
|
chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim())
|
||||||
|
)];
|
||||||
|
|
||||||
|
// Publication date range from chapter upload dates
|
||||||
|
const uploadDates = chapters
|
||||||
|
.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null)
|
||||||
|
.filter((d): d is number => d !== null && !isNaN(d));
|
||||||
|
const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
|
||||||
|
const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
|
||||||
|
|
||||||
|
function formatDate(d: Date) {
|
||||||
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = displayManga.status
|
||||||
|
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const continueChapter = (() => {
|
||||||
|
if (!chapters.length) return null;
|
||||||
|
const asc = [...chapters];
|
||||||
|
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
|
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
||||||
|
const firstUnread = asc.find((c) => !c.isRead);
|
||||||
|
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
||||||
|
return { ch: asc[0], label: "Read again" };
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function toggleLibrary() {
|
||||||
|
if (!manga) return;
|
||||||
|
setTogglingLib(true);
|
||||||
|
const next = !manga.inLibrary;
|
||||||
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||||
|
const updated = { ...manga, inLibrary: next };
|
||||||
|
setManga(updated);
|
||||||
|
// Update cache so subsequent opens reflect new state
|
||||||
|
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
||||||
|
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
setTogglingLib(false);
|
||||||
|
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAll() {
|
||||||
|
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
setQueueingAll(true);
|
||||||
|
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
||||||
|
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
||||||
|
setQueueingAll(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSeriesDetail() {
|
||||||
|
setActiveManga(displayManga);
|
||||||
|
setNavPage("library");
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFolderCreate() {
|
||||||
|
const name = newFolderName.trim();
|
||||||
|
if (!name || !previewManga) return;
|
||||||
|
const newId = addFolder(name);
|
||||||
|
assignMangaToFolder(newId, previewManga.id);
|
||||||
|
setNewFolderName("");
|
||||||
|
setCreatingFolder(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={s.backdrop}
|
||||||
|
ref={backdropRef}
|
||||||
|
onClick={(e) => { if (e.target === backdropRef.current) close(); }}
|
||||||
|
>
|
||||||
|
<div className={s.modal} role="dialog" aria-label="Manga preview">
|
||||||
|
|
||||||
|
{/* ── Cover column ── */}
|
||||||
|
<div className={s.coverCol}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(previewManga.thumbnailUrl)}
|
||||||
|
alt={displayManga.title}
|
||||||
|
className={s.cover}
|
||||||
|
/>
|
||||||
|
{loadingDetail && (
|
||||||
|
<div className={s.coverSpinner}>
|
||||||
|
<CircleNotch size={18} weight="light" className="anim-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.coverActions}>
|
||||||
|
<button
|
||||||
|
className={[s.actionBtn, inLibrary ? s.actionBtnActive : ""].join(" ")}
|
||||||
|
onClick={toggleLibrary}
|
||||||
|
disabled={togglingLib || loadingDetail}
|
||||||
|
>
|
||||||
|
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
||||||
|
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className={s.actionBtn} onClick={openSeriesDetail}>
|
||||||
|
<Books size={13} weight="light" />
|
||||||
|
Series Detail
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Folder picker */}
|
||||||
|
<div className={s.folderWrap} ref={folderRef}>
|
||||||
|
<button
|
||||||
|
className={[s.actionBtn, assignedFolders.length > 0 ? s.actionBtnFolder : ""].join(" ")}
|
||||||
|
onClick={() => setFolderOpen((p) => !p)}
|
||||||
|
>
|
||||||
|
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
|
||||||
|
<span className={s.actionBtnLabel}>
|
||||||
|
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{folderOpen && (
|
||||||
|
<div className={s.folderMenu}>
|
||||||
|
{folders.length === 0 && !creatingFolder && (
|
||||||
|
<p className={s.folderEmpty}>No folders yet</p>
|
||||||
|
)}
|
||||||
|
{folders.map((f) => {
|
||||||
|
const isIn = f.mangaIds.includes(previewManga.id);
|
||||||
|
return (
|
||||||
|
<button key={f.id}
|
||||||
|
className={[s.folderItem, isIn ? s.folderItemOn : ""].join(" ")}
|
||||||
|
onClick={() => isIn
|
||||||
|
? removeMangaFromFolder(f.id, previewManga.id)
|
||||||
|
: assignMangaToFolder(f.id, previewManga.id)}
|
||||||
|
>
|
||||||
|
<Folder size={12} weight={isIn ? "fill" : "light"} />
|
||||||
|
{isIn ? "✓ " : ""}{f.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className={s.folderDivider} />
|
||||||
|
{creatingFolder ? (
|
||||||
|
<div className={s.folderCreateRow}>
|
||||||
|
<input autoFocus className={s.folderInput} placeholder="Folder name…"
|
||||||
|
value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleFolderCreate();
|
||||||
|
if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className={s.folderOkBtn} onClick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className={s.folderNewBtn} onClick={() => setCreatingFolder(true)}>+ New folder</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content column ── */}
|
||||||
|
<div className={s.content}>
|
||||||
|
|
||||||
|
{/* Header — title visible immediately from previewManga */}
|
||||||
|
<div className={s.contentHeader}>
|
||||||
|
<div className={s.titleBlock}>
|
||||||
|
<h2 className={s.title}>{displayManga.title}</h2>
|
||||||
|
{loadingDetail
|
||||||
|
? <div className={s.skByline} />
|
||||||
|
: (displayManga.author || displayManga.artist)
|
||||||
|
? <p className={s.byline}>
|
||||||
|
{[displayManga.author, displayManga.artist]
|
||||||
|
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}
|
||||||
|
</p>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<button className={s.closeBtn} onClick={close}><X size={15} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable body */}
|
||||||
|
<div className={s.contentBody}>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{fetchError && (
|
||||||
|
<div className={s.errorBanner}>{fetchError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Badges ── */}
|
||||||
|
{loadingDetail ? (
|
||||||
|
<div className={s.skRow}>
|
||||||
|
<div className={s.skBadge} />
|
||||||
|
<div className={s.skBadge} style={{ width: 72 }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.badges}>
|
||||||
|
{statusLabel && (
|
||||||
|
<span className={[s.badge,
|
||||||
|
displayManga.status === "ONGOING" ? s.badgeGreen : s.badgeDim
|
||||||
|
].join(" ")}>{statusLabel}</span>
|
||||||
|
)}
|
||||||
|
{displayManga.source && (
|
||||||
|
<span className={[s.badge, (displayManga.source as any).isNsfw ? s.badgeNsfw : ""].join(" ").trim()}>
|
||||||
|
{displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inLibrary && <span className={[s.badge, s.badgeAccent].join(" ")}>In Library</span>}
|
||||||
|
{!loadingChapters && unreadCount > 0 && (
|
||||||
|
<span className={[s.badge, s.badgeUnread].join(" ")}>{unreadCount} unread</span>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && bookmarkCount > 0 && (
|
||||||
|
<span className={s.badge}>{bookmarkCount} bookmarked</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Chapter section — visually separated box ── */}
|
||||||
|
<div className={s.chapterBox}>
|
||||||
|
{loadingChapters ? (
|
||||||
|
<div className={s.chapterLoading}>
|
||||||
|
<CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
<span className={s.chapterLoadingLabel}>Loading chapters…</span>
|
||||||
|
</div>
|
||||||
|
) : totalCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className={s.chapterMeta}>
|
||||||
|
<span className={s.chapterLabel}>
|
||||||
|
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||||
|
{readCount > 0 && ` · ${readCount} read`}
|
||||||
|
{unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`}
|
||||||
|
{downloadedCount > 0 && ` · ${downloadedCount} dl`}
|
||||||
|
</span>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button className={s.dlAllBtn} onClick={downloadAll} disabled={queueingAll}>
|
||||||
|
{queueingAll && <CircleNotch size={11} weight="light" className="anim-spin" />}
|
||||||
|
{queueingAll ? "Queuing…" : "Download unread"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{readCount > 0 && (
|
||||||
|
<div className={s.progressTrack}>
|
||||||
|
<div className={s.progressFill} style={{ width: `${(readCount / totalCount) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{continueChapter && (
|
||||||
|
<button className={s.readBtn}
|
||||||
|
onClick={() => { openReader(continueChapter.ch, chapters); close(); }}
|
||||||
|
>
|
||||||
|
<Play size={12} weight="fill" />
|
||||||
|
{continueChapter.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : !loadingDetail ? (
|
||||||
|
<span className={s.chapterLabel} style={{ color: "var(--text-faint)" }}>
|
||||||
|
No chapters in local library
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Description — clearly separated from chapter block ── */}
|
||||||
|
{loadingDetail ? (
|
||||||
|
<div className={s.skDesc}>
|
||||||
|
<div className={s.skLine} style={{ width: "100%" }} />
|
||||||
|
<div className={s.skLine} style={{ width: "88%" }} />
|
||||||
|
<div className={s.skLine} style={{ width: "70%" }} />
|
||||||
|
</div>
|
||||||
|
) : displayManga.description ? (
|
||||||
|
<div className={s.descBlock}>
|
||||||
|
<p className={[s.desc, descExpanded ? s.descOpen : ""].join(" ")}>
|
||||||
|
{displayManga.description}
|
||||||
|
</p>
|
||||||
|
{displayManga.description.length > 220 && (
|
||||||
|
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
|
||||||
|
{descExpanded ? "Show less" : "Show more"}
|
||||||
|
<CaretDown size={10} weight="light" style={{
|
||||||
|
transform: descExpanded ? "rotate(180deg)" : "none",
|
||||||
|
transition: "transform 0.15s ease",
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Genre tags ── */}
|
||||||
|
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
|
||||||
|
<div className={s.genres}>
|
||||||
|
{displayManga.genre.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
className={[s.genreTag, s.genreTagClickable].join(" ")}
|
||||||
|
title={`Browse "${g}"`}
|
||||||
|
onClick={() => {
|
||||||
|
setGenreFilter(g);
|
||||||
|
setNavPage("explore");
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Metadata table ── */}
|
||||||
|
{!loadingDetail && (
|
||||||
|
<div className={s.metaTable}>
|
||||||
|
{displayManga.author && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Author</span>
|
||||||
|
<span className={s.metaVal}>{displayManga.author}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayManga.artist && displayManga.artist !== displayManga.author && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Artist</span>
|
||||||
|
<span className={s.metaVal}>{displayManga.artist}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{statusLabel && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Status</span>
|
||||||
|
<span className={s.metaVal}>{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayManga.source && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Source</span>
|
||||||
|
<span className={s.metaVal}>{displayManga.source.displayName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && scanlators.length > 0 && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span>
|
||||||
|
<span className={s.metaVal}>{scanlators.join(", ")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && firstUpload && lastUpload && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Published</span>
|
||||||
|
<span className={s.metaVal}>
|
||||||
|
{firstUpload.getTime() === lastUpload.getTime()
|
||||||
|
? formatDate(firstUpload)
|
||||||
|
: `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && downloadedCount > 0 && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Downloaded</span>
|
||||||
|
<span className={s.metaVal}>{downloadedCount} / {totalCount} chapters</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && bookmarkCount > 0 && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Bookmarks</span>
|
||||||
|
<span className={s.metaVal}>{bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayManga.realUrl && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Link</span>
|
||||||
|
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" className={s.metaLink}>
|
||||||
|
Open <ArrowSquareOut size={11} weight="light" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
.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; }
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<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 RecentActivity from "./RecentActivity.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";
|
|
||||||
import Tracking from "../pages/Tracking.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"}
|
|
||||||
<RecentActivity />
|
|
||||||
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
|
|
||||||
<GenreDrillPage />
|
|
||||||
{:else if store.navPage === "explore" || store.navPage === "sources"}
|
|
||||||
<Discover />
|
|
||||||
{:else if store.navPage === "downloads"}
|
|
||||||
<Downloads />
|
|
||||||
{:else if store.navPage === "extensions"}
|
|
||||||
<Extensions />
|
|
||||||
{:else if store.navPage === "tracking"}
|
|
||||||
<Tracking />
|
|
||||||
{: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>
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useStore } from "../../store";
|
||||||
|
import Sidebar from "./Sidebar";
|
||||||
|
import Library from "../pages/Library";
|
||||||
|
import SeriesDetail from "../pages/SeriesDetail";
|
||||||
|
import History from "../pages/History";
|
||||||
|
import Search from "../pages/Search";
|
||||||
|
import Explore from "../explore/Explore";
|
||||||
|
import DownloadQueue from "../downloads/DownloadQueue";
|
||||||
|
import ExtensionList from "../extensions/ExtensionList";
|
||||||
|
import s from "./Layout.module.css";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const navPage = useStore((s) => s.navPage);
|
||||||
|
const activeManga = useStore((s) => s.activeManga);
|
||||||
|
|
||||||
|
function renderContent() {
|
||||||
|
if (activeManga) return <SeriesDetail />;
|
||||||
|
switch (navPage) {
|
||||||
|
case "library": return <Library />;
|
||||||
|
case "search": return <Search />;
|
||||||
|
case "history": return <History />;
|
||||||
|
case "sources": return <Explore />;
|
||||||
|
case "explore": return <Explore />;
|
||||||
|
case "downloads": return <DownloadQueue />;
|
||||||
|
case "extensions": return <ExtensionList />;
|
||||||
|
default: return <Library />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.root}>
|
||||||
|
<Sidebar />
|
||||||
|
<main className={s.main}>{renderContent()}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
<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.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.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 }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resume: navigate to the manga's SeriesDetail (which will pick up from
|
|
||||||
// activeChapterList once chapters load). We can't hold a stale chapter list
|
|
||||||
// here — SeriesDetail fetches fresh chapters itself.
|
|
||||||
function resume(session: Session) {
|
|
||||||
setActiveManga({
|
|
||||||
id: session.mangaId,
|
|
||||||
title: session.mangaTitle,
|
|
||||||
thumbnailUrl: session.thumbnailUrl,
|
|
||||||
inLibrary: false,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
if (!confirmClear) { confirmClear = true; setTimeout(() => confirmClear = false, 3000); return; }
|
|
||||||
clearHistory(); confirmClear = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<div class="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 history…" bind:value={search} />
|
|
||||||
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.history.length > 0}
|
|
||||||
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
|
|
||||||
title={confirmClear ? "Click again to confirm" : "Clear history"}>
|
|
||||||
<Trash size={14} weight="light" />
|
|
||||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if store.readingStats.totalChaptersRead > 0}
|
|
||||||
<div class="stats-bar">
|
|
||||||
<div class="stat-group">
|
|
||||||
<Fire size={13} weight="fill" class="stat-fire" />
|
|
||||||
<span class="stat-val accent">{store.readingStats.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.readingStats.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.readingStats.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.readingStats.totalMangaRead}</span>
|
|
||||||
<span class="stat-label">series</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<span class="stat-val muted">{store.readingStats.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.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="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; }
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.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; 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>
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
.root {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-void);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--sp-4) 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
overflow: visible;
|
||||||
|
/* Explicit reset — prevents browser from injecting a default button background */
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
transition: opacity var(--t-base), transform var(--t-base);
|
||||||
|
padding: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||||
|
.logo:active { transform: scale(0.92); }
|
||||||
|
/* Kill the focus ring that can render as a coloured glow on some GTK themes */
|
||||||
|
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background-color: var(--accent);
|
||||||
|
mask-image: url("../../assets/moku-icon.svg");
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-image: url("../../assets/moku-icon.svg");
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint);
|
||||||
|
/* Explicit resets — the green overlay was browser default button styles bleeding through */
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-md); }
|
||||||
|
|
||||||
|
.tabActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
/* Prevent hover state from overriding active colour */
|
||||||
|
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
width: 100%; padding: var(--sp-3) var(--sp-2) 0;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsBtn {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint);
|
||||||
|
/* Same explicit resets */
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
||||||
|
}
|
||||||
|
.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||||
|
.settingsBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
|
|
||||||
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
|
||||||
import 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 },
|
|
||||||
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
|
|
||||||
];
|
|
||||||
|
|
||||||
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>
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
Books, DownloadSimple, PuzzlePiece, Compass,
|
||||||
|
GearSix, ClockCounterClockwise, MagnifyingGlass,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { useStore, type NavPage } from "../../store";
|
||||||
|
import s from "./Sidebar.module.css";
|
||||||
|
|
||||||
|
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
||||||
|
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||||
|
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||||
|
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||||
|
{ id: "explore", icon: <Compass size={18} weight="light" />, label: "Explore" },
|
||||||
|
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
||||||
|
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const navPage = useStore((state) => state.navPage);
|
||||||
|
const setNavPage = useStore((state) => state.setNavPage);
|
||||||
|
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||||
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
|
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||||
|
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||||
|
const openSettings = useStore((state) => state.openSettings);
|
||||||
|
|
||||||
|
function navigate(id: NavPage) {
|
||||||
|
setNavPage(id);
|
||||||
|
setActiveManga(null);
|
||||||
|
setGenreFilter("");
|
||||||
|
if (id !== "explore") setActiveSource(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
setNavPage("library");
|
||||||
|
setActiveSource(null);
|
||||||
|
setActiveManga(null);
|
||||||
|
setLibraryFilter("library");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={s.root}>
|
||||||
|
{/* Logo click → back to library root */}
|
||||||
|
<button className={s.logo} onClick={goHome} title="Go to Library" aria-label="Go to Library">
|
||||||
|
<div className={s.logoIcon} />
|
||||||
|
</button>
|
||||||
|
<nav className={s.nav}>
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button key={tab.id} title={tab.label}
|
||||||
|
onClick={() => navigate(tab.id)}
|
||||||
|
className={[s.tab, navPage === tab.id ? s.tabActive : ""].join(" ")}>
|
||||||
|
{tab.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className={s.bottom}>
|
||||||
|
<button className={s.settingsBtn} onClick={openSettings} title="Settings">
|
||||||
|
<GearSix size={18} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { store } from "../../store/state.svelte";
|
|
||||||
import logoUrl from "../../assets/moku-icon-splash.svg";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mode?: "loading" | "idle";
|
|
||||||
ringFull?: boolean;
|
|
||||||
failed?: boolean;
|
|
||||||
notConfigured?: boolean;
|
|
||||||
showCards?: boolean;
|
|
||||||
showFps?: boolean;
|
|
||||||
onReady?: () => void;
|
|
||||||
onRetry?: () => void;
|
|
||||||
onBypass?: () => void;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
|
|
||||||
showCards = true, showFps = false, onReady, onRetry, onBypass, onDismiss }: Props = $props();
|
|
||||||
|
|
||||||
const lockEnabled = $derived(
|
|
||||||
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
|
|
||||||
);
|
|
||||||
|
|
||||||
let pinEntry = $state("");
|
|
||||||
let pinShake = $state(false);
|
|
||||||
let pinUnlocked = $state(false);
|
|
||||||
let pinVisible = $state(false);
|
|
||||||
|
|
||||||
function submitPin() {
|
|
||||||
if (pinEntry === store.settings.appLockPin) {
|
|
||||||
pinUnlocked = true;
|
|
||||||
pinEntry = "";
|
|
||||||
if (mode === "idle") triggerExit(onDismiss);
|
|
||||||
} else {
|
|
||||||
pinShake = true;
|
|
||||||
pinEntry = "";
|
|
||||||
setTimeout(() => pinShake = false, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPinKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Enter") { submitPin(); return; }
|
|
||||||
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
|
|
||||||
if (/^\d$/.test(e.key)) {
|
|
||||||
pinEntry = (pinEntry + e.key).slice(0, 8);
|
|
||||||
if (pinEntry.length >= (store.settings.appLockPin?.length ?? 4)) submitPin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRetry() { onRetry?.(); }
|
|
||||||
function handleBypass() { onBypass?.(); }
|
|
||||||
|
|
||||||
const EXIT_MS = 320;
|
|
||||||
const PHASE1_TARGET = 0.85;
|
|
||||||
const PHASE1_MS = 3000;
|
|
||||||
const PHASE2_TARGET = 0.95;
|
|
||||||
const PHASE2_MS = 10000;
|
|
||||||
|
|
||||||
let dots = $state("");
|
|
||||||
let ringProg = $state(0.025);
|
|
||||||
let exiting = $state(false);
|
|
||||||
let exitLock = false;
|
|
||||||
|
|
||||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
|
||||||
|
|
||||||
function triggerExit(cb?: () => void) {
|
|
||||||
if (exitLock) return;
|
|
||||||
exitLock = true;
|
|
||||||
exiting = true;
|
|
||||||
setTimeout(() => cb?.(), EXIT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
let animFrame: number;
|
|
||||||
let animStart: number | null = null;
|
|
||||||
let animPhase = 1;
|
|
||||||
|
|
||||||
function animateRing(ts: number) {
|
|
||||||
if (exitLock) return;
|
|
||||||
if (animStart === null) animStart = ts;
|
|
||||||
const elapsed = ts - animStart;
|
|
||||||
|
|
||||||
if (animPhase === 1) {
|
|
||||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (mode === "loading" && !failed && !notConfigured) {
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
|
||||||
return () => cancelAnimationFrame(animFrame);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (ringFull) {
|
|
||||||
cancelAnimationFrame(animFrame);
|
|
||||||
ringProg = 1;
|
|
||||||
if (lockEnabled && !pinUnlocked) {
|
|
||||||
setTimeout(() => { pinVisible = true; }, 400);
|
|
||||||
} else {
|
|
||||||
setTimeout(() => triggerExit(onReady), 650);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const dotsInterval = setInterval(() => {
|
|
||||||
dots = dots.length >= 3 ? "" : dots + ".";
|
|
||||||
}, 420);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (mode === "idle" && onDismiss) {
|
|
||||||
if (lockEnabled) {
|
|
||||||
return () => clearInterval(dotsInterval);
|
|
||||||
}
|
|
||||||
const handler = () => triggerExit(onDismiss);
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
|
||||||
window.addEventListener("mousedown", handler, { once: true });
|
|
||||||
window.addEventListener("touchstart", handler, { once: true });
|
|
||||||
}, 200);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(t);
|
|
||||||
clearInterval(dotsInterval);
|
|
||||||
window.removeEventListener("keydown", handler);
|
|
||||||
window.removeEventListener("mousedown", handler);
|
|
||||||
window.removeEventListener("touchstart", handler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return () => clearInterval(dotsInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
|
||||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
|
||||||
|
|
||||||
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(); };
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const needsPin =
|
|
||||||
(mode === "idle" && lockEnabled) ||
|
|
||||||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
|
|
||||||
if (!needsPin) return;
|
|
||||||
window.addEventListener("keydown", onPinKey);
|
|
||||||
return () => window.removeEventListener("keydown", onPinKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (pinUnlocked && mode !== "idle") {
|
|
||||||
triggerExit(onReady);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const 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' && !lockEnabled ? 'pointer' : 'default'}">
|
|
||||||
{#if showCards}
|
|
||||||
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
|
||||||
{#if showFps}
|
|
||||||
<span bind:this={fpsEl} style="position:absolute;top:8px;right:8px;font-family:var(--font-ui);font-size:10px;color:var(--text-faint);z-index:2;pointer-events:none"></span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mode === "idle" && lockEnabled}
|
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
|
|
||||||
<div style="position:relative;width:96px;height:96px">
|
|
||||||
<div class="logo-glow"></div>
|
|
||||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:96px;height:96px;border-radius:22px;display:block;position:relative" />
|
|
||||||
</div>
|
|
||||||
<div class="pin-block">
|
|
||||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
|
||||||
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
|
|
||||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if mode === "idle"}
|
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
|
||||||
<div style="position:relative;width: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}
|
|
||||||
class="loading-ring"
|
|
||||||
class:ring-hide={lockEnabled && pinVisible}
|
|
||||||
style="position:absolute;pointer-events:none;top:{ringTop}px;left:{ringLeft}px">
|
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-dasharray="{ringArc} {ringCirc}"
|
|
||||||
transform="rotate(-90 {ringC} {ringC})"
|
|
||||||
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" />
|
|
||||||
</div>
|
|
||||||
<p class="title-label">moku</p>
|
|
||||||
|
|
||||||
<div class="bottom-area" style="z-index:1">
|
|
||||||
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
|
||||||
{#if failed || notConfigured}
|
|
||||||
<div class="error-box">
|
|
||||||
<p class="error-label">
|
|
||||||
{failed ? "Could not reach server" : "Server not configured"}
|
|
||||||
</p>
|
|
||||||
<div class="error-actions">
|
|
||||||
<button class="err-btn" onclick={handleRetry}>Retry</button>
|
|
||||||
<button class="err-btn err-btn--primary" onclick={handleBypass}>Enter app</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if lockEnabled}
|
|
||||||
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
|
|
||||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
|
||||||
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
|
|
||||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
|
||||||
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
|
||||||
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
|
||||||
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
|
||||||
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
|
||||||
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
|
||||||
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
|
|
||||||
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
|
||||||
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
|
||||||
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
|
|
||||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; }
|
|
||||||
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
|
|
||||||
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
|
|
||||||
.error-actions { display: flex; gap: 6px; }
|
|
||||||
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
|
|
||||||
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
|
||||||
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
|
|
||||||
|
|
||||||
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
|
|
||||||
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
|
|
||||||
.status-slot-hide { opacity: 0; pointer-events: none; }
|
|
||||||
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
|
||||||
.loading-ring { transition: opacity 0.5s ease; }
|
|
||||||
.ring-hide { opacity: 0; }
|
|
||||||
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
|
|
||||||
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
|
||||||
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
|
|
||||||
.pin-dots { display: flex; gap: 12px; align-items: center; }
|
|
||||||
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
|
|
||||||
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
|
|
||||||
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
|
||||||
.pin-shake { animation: pinShake 0.42s ease; }
|
|
||||||
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import logoUrl from "../../assets/moku-icon.svg";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
|
export type SplashMode = "loading" | "idle";
|
||||||
|
export const EXIT_MS = 320;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: SplashMode;
|
||||||
|
ringFull?: boolean;
|
||||||
|
failed?: boolean;
|
||||||
|
showCards?: boolean;
|
||||||
|
showFps?: boolean;
|
||||||
|
onReady?: () => void;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hash ──────────────────────────────────────────────────────────────────────
|
||||||
|
function hash(n: number): number {
|
||||||
|
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||||
|
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
||||||
|
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card definition ───────────────────────────────────────────────────────────
|
||||||
|
interface CardDef {
|
||||||
|
layer: 0 | 1 | 2;
|
||||||
|
cx: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
lines: number;
|
||||||
|
alpha: number;
|
||||||
|
speed: number;
|
||||||
|
cycleSec: number;
|
||||||
|
phase: number;
|
||||||
|
travel: number;
|
||||||
|
yStart: number;
|
||||||
|
angleStart: number;
|
||||||
|
tilt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||||
|
|
||||||
|
const LAYER_CFG = [
|
||||||
|
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||||
|
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
||||||
|
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const BUF = 80;
|
||||||
|
const COLS = 14;
|
||||||
|
|
||||||
|
function buildCards(vw: number, vh: number): { cards: CardDef[]; trigs: CardTrig[] } {
|
||||||
|
const cards: CardDef[] = [];
|
||||||
|
const laneW = vw / COLS;
|
||||||
|
for (let layer = 0; layer < 3; layer++) {
|
||||||
|
const cfg = LAYER_CFG[layer];
|
||||||
|
for (let col = 0; col < COLS; col++) {
|
||||||
|
const seed = col * 31 + layer * 97 + 7;
|
||||||
|
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
||||||
|
const h = w * 1.44;
|
||||||
|
const maxNudge = (laneW - w) / 2 - 2;
|
||||||
|
const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge);
|
||||||
|
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
||||||
|
const travel = vh + h + BUF;
|
||||||
|
cards.push({
|
||||||
|
layer: layer as 0 | 1 | 2,
|
||||||
|
cx, w, h,
|
||||||
|
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||||
|
alpha: cfg.alpha,
|
||||||
|
speed,
|
||||||
|
cycleSec: travel / speed,
|
||||||
|
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||||
|
travel,
|
||||||
|
yStart: vh + h / 2 + BUF / 2,
|
||||||
|
angleStart: hash(seed + 3) * 50 - 25,
|
||||||
|
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const trigs: CardTrig[] = cards.map(c => ({
|
||||||
|
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||||
|
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||||
|
tiltRad: c.tilt * (Math.PI / 180),
|
||||||
|
}));
|
||||||
|
return { cards, trigs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rounded rect ──────────────────────────────────────────────────────────────
|
||||||
|
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||||
|
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||||
|
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||||
|
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stamp builder ─────────────────────────────────────────────────────────────
|
||||||
|
const STAMP_PAD = 6;
|
||||||
|
|
||||||
|
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||||
|
const logW = Math.ceil(c.w + STAMP_PAD * 2);
|
||||||
|
const logH = Math.ceil(c.h + STAMP_PAD * 2);
|
||||||
|
const oc = document.createElement("canvas");
|
||||||
|
oc.width = Math.round(logW * dpr);
|
||||||
|
oc.height = Math.round(logH * dpr);
|
||||||
|
const ctx = oc.getContext("2d")!;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const x0 = STAMP_PAD;
|
||||||
|
const y0 = STAMP_PAD;
|
||||||
|
const coverH = (c.w * 0.72) * 1.05;
|
||||||
|
const lineY0 = y0 + 3 + coverH + 5;
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
||||||
|
rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.07)";
|
||||||
|
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.15)";
|
||||||
|
rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.08)";
|
||||||
|
rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
||||||
|
|
||||||
|
for (let li = 0; li < c.lines; li++) {
|
||||||
|
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
||||||
|
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return oc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vignette builder ──────────────────────────────────────────────────────────
|
||||||
|
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||||
|
const oc = document.createElement("canvas");
|
||||||
|
oc.width = Math.round(vw * dpr);
|
||||||
|
oc.height = Math.round(vh * dpr);
|
||||||
|
const ctx = oc.getContext("2d")!;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
||||||
|
g.addColorStop(0.15, "rgba(0,0,0,0)");
|
||||||
|
g.addColorStop(1, "rgba(0,0,0,0.82)");
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, vw, vh);
|
||||||
|
return oc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw frame ────────────────────────────────────────────────────────────────
|
||||||
|
function drawFrame(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
t: number,
|
||||||
|
cw: number,
|
||||||
|
ch: number,
|
||||||
|
dpr: number,
|
||||||
|
cards: CardDef[],
|
||||||
|
trigs: CardTrig[],
|
||||||
|
stamps: HTMLCanvasElement[],
|
||||||
|
vignette: HTMLCanvasElement,
|
||||||
|
) {
|
||||||
|
ctx.clearRect(0, 0, cw, ch);
|
||||||
|
|
||||||
|
for (let i = 0; i < cards.length; i++) {
|
||||||
|
const c = cards[i];
|
||||||
|
const p = ((t / c.cycleSec) + c.phase) % 1;
|
||||||
|
|
||||||
|
const alpha = p < 0.07
|
||||||
|
? (p / 0.07) * c.alpha
|
||||||
|
: p > 0.86
|
||||||
|
? ((1 - p) / 0.14) * c.alpha
|
||||||
|
: c.alpha;
|
||||||
|
|
||||||
|
if (alpha < 0.005) continue;
|
||||||
|
|
||||||
|
const cy = c.yStart - p * c.travel;
|
||||||
|
const tg = trigs[i];
|
||||||
|
const delta = tg.tiltRad * p;
|
||||||
|
const cosDelta = Math.cos(delta);
|
||||||
|
const sinDelta = Math.sin(delta);
|
||||||
|
const cos = tg.cosA * cosDelta - tg.sinA * sinDelta;
|
||||||
|
const sin = tg.sinA * cosDelta + tg.cosA * sinDelta;
|
||||||
|
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.setTransform(
|
||||||
|
cos * dpr, sin * dpr,
|
||||||
|
-sin * dpr, cos * dpr,
|
||||||
|
c.cx * dpr, cy * dpr,
|
||||||
|
);
|
||||||
|
// Draw stamp at its natural logical size.
|
||||||
|
// The stamp was baked at (logical * dpr) physical pixels.
|
||||||
|
// setTransform already applied dpr scaling, so drawing at logical size
|
||||||
|
// means the stamp maps 1:1 to physical pixels — zero resampling, zero blur.
|
||||||
|
const sw = stamps[i].width / dpr;
|
||||||
|
const sh = stamps[i].height / dpr;
|
||||||
|
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.drawImage(vignette, 0, 0, cw, ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ring ──────────────────────────────────────────────────────────────────────
|
||||||
|
function Ring({ progress }: { progress: number }) {
|
||||||
|
const r = 44, sw = 2, pad = 8;
|
||||||
|
const size = (r + pad) * 2, c = r + pad;
|
||||||
|
const circ = 2 * Math.PI * r;
|
||||||
|
const arc = circ * Math.min(Math.max(progress, 0.025), 0.999);
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} style={{
|
||||||
|
position: "absolute", pointerEvents: "none",
|
||||||
|
top: -((size - 80) / 2), left: -((size - 80) / 2),
|
||||||
|
}}>
|
||||||
|
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={sw} />
|
||||||
|
<circle cx={c} cy={c} r={r} fill="none" stroke="#4ade80" strokeWidth={sw}
|
||||||
|
strokeLinecap="round" strokeDasharray={`${arc} ${circ}`}
|
||||||
|
transform={`rotate(-90 ${c} ${c})`}
|
||||||
|
style={{ transition: "stroke-dasharray 0.55s cubic-bezier(0.4,0,0.2,1)" }} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FPS counter ───────────────────────────────────────────────────────────────
|
||||||
|
function FpsCounter() {
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
const times = useRef<number[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0;
|
||||||
|
function tick(now: number) {
|
||||||
|
const arr = times.current;
|
||||||
|
arr.push(now);
|
||||||
|
if (arr.length > 60) arr.shift();
|
||||||
|
if (arr.length > 1 && divRef.current) {
|
||||||
|
const fps = Math.round((arr.length - 1) / ((arr[arr.length - 1] - arr[0]) / 1000));
|
||||||
|
divRef.current.textContent = `${fps} fps`;
|
||||||
|
divRef.current.style.color = fps >= 55 ? "#4ade80" : fps >= 30 ? "#facc15" : "#f87171";
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={divRef} style={{
|
||||||
|
position: "fixed", top: 10, right: 14, zIndex: 10001,
|
||||||
|
fontFamily: "var(--font-mono, 'Courier New', monospace)",
|
||||||
|
fontSize: 11, fontWeight: 600, letterSpacing: "0.08em",
|
||||||
|
color: "#4ade80",
|
||||||
|
background: "rgba(0,0,0,0.55)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.12)",
|
||||||
|
borderRadius: 4, padding: "2px 7px",
|
||||||
|
userSelect: "none", pointerEvents: "none",
|
||||||
|
}}>-- fps</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── CardCanvas ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Strategy: best of both worlds.
|
||||||
|
//
|
||||||
|
// LAYOUT → logical pixels (window.innerWidth/Height or Tauri innerSize/scale)
|
||||||
|
// Cards fill the actual window shape correctly at any size.
|
||||||
|
//
|
||||||
|
// QUALITY → physical pixels (Tauri innerSize + scaleFactor)
|
||||||
|
// Canvas buffer = physical pixels, stamps baked at the true OS DPR.
|
||||||
|
// No WebKitGTK lies, no late compositor hints, always pixel-perfect.
|
||||||
|
//
|
||||||
|
// On every resize both are re-derived together so fullscreen, half-split,
|
||||||
|
// monitor switch — all produce crisp, correctly-proportioned cards.
|
||||||
|
//
|
||||||
|
function CardCanvas({ showFps }: { showFps: boolean }) {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = ref.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false });
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = "high";
|
||||||
|
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
|
||||||
|
// ── Live render state ────────────────────────────────────────────────────
|
||||||
|
// The frame loop only ever reads from `live`. syncSize builds a complete
|
||||||
|
// replacement object off-thread then swaps it in one atomic assignment —
|
||||||
|
// no frame ever sees a half-rebuilt state.
|
||||||
|
interface RenderState {
|
||||||
|
cards: ReturnType<typeof buildCards>["cards"];
|
||||||
|
trigs: ReturnType<typeof buildCards>["trigs"];
|
||||||
|
stamps: HTMLCanvasElement[];
|
||||||
|
vignette: HTMLCanvasElement;
|
||||||
|
CW: number; CH: number; scale: number;
|
||||||
|
}
|
||||||
|
let live: RenderState | null = null;
|
||||||
|
|
||||||
|
// Track what we last built so we skip no-op resize events.
|
||||||
|
let lastLogW = 0, lastLogH = 0, lastScale = 0;
|
||||||
|
// Debounce: if a new resize arrives while one is in-flight, we only
|
||||||
|
// want the most recent result. A simple generation counter handles this.
|
||||||
|
let buildGen = 0;
|
||||||
|
|
||||||
|
async function syncSize() {
|
||||||
|
const gen = ++buildGen;
|
||||||
|
|
||||||
|
const [phys, scale] = await Promise.all([
|
||||||
|
win.innerSize(),
|
||||||
|
win.scaleFactor(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Another resize fired while we were awaiting — our result is stale.
|
||||||
|
if (gen !== buildGen) return;
|
||||||
|
|
||||||
|
const physW = phys.width;
|
||||||
|
const physH = phys.height;
|
||||||
|
const logW = physW / scale;
|
||||||
|
const logH = physH / scale;
|
||||||
|
|
||||||
|
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
||||||
|
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
||||||
|
|
||||||
|
// Build everything into a local staging object — nothing visible changes yet.
|
||||||
|
const built = buildCards(logW, logH);
|
||||||
|
const stamps = built.cards.map(c => buildStamp(c, scale));
|
||||||
|
const vig = buildVignette(logW, logH, scale);
|
||||||
|
|
||||||
|
// One atomic swap — the frame loop immediately sees the complete new state.
|
||||||
|
// Canvas dimensions are updated here too so they're always in sync with
|
||||||
|
// the render state that uses them.
|
||||||
|
canvas!.width = physW;
|
||||||
|
canvas!.height = physH;
|
||||||
|
live = {
|
||||||
|
cards: built.cards, trigs: built.trigs,
|
||||||
|
stamps, vignette: vig,
|
||||||
|
CW: physW, CH: physH, scale,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[SplashScreen] syncSize: logical ${Math.round(logW)}×${Math.round(logH)}`,
|
||||||
|
`physical ${physW}×${physH} @${scale.toFixed(3)}×`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => syncSize());
|
||||||
|
ro.observe(canvas);
|
||||||
|
syncSize();
|
||||||
|
|
||||||
|
let raf = 0, t0 = -1;
|
||||||
|
function frame(now: number) {
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
if (!live) return;
|
||||||
|
if (t0 < 0) t0 = now;
|
||||||
|
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
||||||
|
drawFrame(ctx!, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<canvas ref={ref} style={{
|
||||||
|
position: "absolute", inset: 0, pointerEvents: "none",
|
||||||
|
width: "100%", height: "100%",
|
||||||
|
}} />
|
||||||
|
{showFps && <FpsCounter />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Static CSS ────────────────────────────────────────────────────────────────
|
||||||
|
const STATIC_CSS = `
|
||||||
|
@keyframes spIn { from{opacity:0;transform:scale(1.015)} to{opacity:1;transform:scale(1)} }
|
||||||
|
@keyframes spOut { from{opacity:1;transform:scale(1)} to{opacity:0;transform:scale(0.96)} }
|
||||||
|
@keyframes logoBreathe {
|
||||||
|
0%,100%{transform:scale(1);filter:drop-shadow(0 0 0px rgba(255,255,255,0))}
|
||||||
|
50% {transform:scale(1.04);filter:drop-shadow(0 0 18px rgba(255,255,255,0.12))}
|
||||||
|
}
|
||||||
|
@keyframes hintFade { 0%,100%{opacity:0.35} 50%{opacity:0.7} }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
export default function SplashScreen({
|
||||||
|
mode, ringFull = false, failed = false,
|
||||||
|
showCards = true, showFps = false,
|
||||||
|
onReady, onRetry, onDismiss,
|
||||||
|
}: Props) {
|
||||||
|
const [dots, setDots] = useState("");
|
||||||
|
const [ringProg, setRingProg] = useState(0.025);
|
||||||
|
const [exiting, setExiting] = useState(false);
|
||||||
|
const exitLock = useRef(false);
|
||||||
|
|
||||||
|
function triggerExit(cb?: () => void) {
|
||||||
|
if (exitLock.current) return;
|
||||||
|
exitLock.current = true;
|
||||||
|
setExiting(true);
|
||||||
|
setTimeout(() => cb?.(), EXIT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ringFull) return;
|
||||||
|
setRingProg(1);
|
||||||
|
const t = setTimeout(() => triggerExit(onReady), 650);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [ringFull]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setDots(d => d.length >= 3 ? "" : d + "."), 420);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "idle" || !onDismiss) return;
|
||||||
|
function handler() { triggerExit(onDismiss); }
|
||||||
|
// Delay registering listeners by one frame so the event that triggered
|
||||||
|
// idle (mousemove/mousedown) doesn't immediately dismiss the splash.
|
||||||
|
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);
|
||||||
|
window.removeEventListener("keydown", handler);
|
||||||
|
window.removeEventListener("mousedown", handler);
|
||||||
|
window.removeEventListener("touchstart", handler);
|
||||||
|
};
|
||||||
|
}, [mode, onDismiss]);
|
||||||
|
|
||||||
|
const isIdle = mode === "idle";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 9999,
|
||||||
|
background: "var(--bg-base)", overflow: "hidden",
|
||||||
|
display: "flex", flexDirection: "column",
|
||||||
|
alignItems: "center", justifyContent: "center",
|
||||||
|
cursor: isIdle ? "pointer" : "default",
|
||||||
|
animation: exiting
|
||||||
|
? `spOut ${EXIT_MS}ms cubic-bezier(0.4,0,1,1) both`
|
||||||
|
: "spIn 0.35s cubic-bezier(0,0,0.2,1) both",
|
||||||
|
}}>
|
||||||
|
<style>{STATIC_CSS}</style>
|
||||||
|
|
||||||
|
{showCards && <CardCanvas showFps={showFps} />}
|
||||||
|
|
||||||
|
{isIdle ? (
|
||||||
|
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||||
|
<div style={{ position: "relative", width: 128, height: 128, marginBottom: 32 }}>
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", inset: -20, borderRadius: "50%",
|
||||||
|
background: "radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%)",
|
||||||
|
animation: "logoBreathe 4s ease-in-out infinite",
|
||||||
|
}} />
|
||||||
|
<img src={logoUrl} alt="Moku" style={{
|
||||||
|
width: 128, height: 128, borderRadius: 28,
|
||||||
|
display: "block", position: "relative",
|
||||||
|
animation: "logoBreathe 4s ease-in-out infinite",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)",
|
||||||
|
letterSpacing: "0.22em", textTransform: "uppercase",
|
||||||
|
margin: 0, userSelect: "none",
|
||||||
|
animation: "hintFade 3.5s ease-in-out infinite",
|
||||||
|
}}>press any key to continue</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ position: "relative", width: 80, height: 80, marginBottom: 20, zIndex: 1 }}>
|
||||||
|
{!failed && <Ring progress={ringProg} />}
|
||||||
|
<img src={logoUrl} alt="Moku"
|
||||||
|
style={{ width: 80, height: 80, borderRadius: 18, display: "block" }} />
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
fontFamily: "var(--font-ui)", fontSize: 11, fontWeight: 500,
|
||||||
|
letterSpacing: "0.26em", textTransform: "uppercase",
|
||||||
|
color: "var(--text-secondary)", margin: "0 0 8px",
|
||||||
|
zIndex: 1, userSelect: "none",
|
||||||
|
}}>moku</p>
|
||||||
|
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
||||||
|
{failed ? (
|
||||||
|
<>
|
||||||
|
<p style={{ fontFamily: "var(--font-ui)", fontSize: 11, color: "var(--color-error)", letterSpacing: "0.1em", margin: 0 }}>
|
||||||
|
Could not reach Suwayomi
|
||||||
|
</p>
|
||||||
|
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.05em", margin: 0, textAlign: "center", maxWidth: 240, lineHeight: "1.6" }}>
|
||||||
|
Make sure tachidesk-server is on your PATH
|
||||||
|
</p>
|
||||||
|
<button onClick={onRetry} style={{
|
||||||
|
marginTop: 4, padding: "5px 16px", borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--border-dim)", background: "var(--bg-raised)",
|
||||||
|
color: "var(--text-muted)", cursor: "pointer",
|
||||||
|
fontFamily: "var(--font-ui)", fontSize: 11, letterSpacing: "0.08em",
|
||||||
|
}}>Retry</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.12em", margin: 0, minWidth: 160, textAlign: "center" }}>
|
||||||
|
{ringFull ? "Ready" : `Initializing server${dots}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
const isMac = platform() === "macos";
|
|
||||||
|
|
||||||
let isFullscreen = $state(false);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
isFullscreen = await win.isFullscreen();
|
|
||||||
const unlisten = await win.onResized(async () => {
|
|
||||||
isFullscreen = await win.isFullscreen();
|
|
||||||
});
|
|
||||||
return unlisten;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !isFullscreen}
|
|
||||||
<div class="bar" data-tauri-drag-region>
|
|
||||||
{#if isMac}<div class="mac-spacer"></div>{/if}
|
|
||||||
<span class="title" data-tauri-drag-region>Moku</span>
|
|
||||||
{#if !isMac}
|
|
||||||
<div class="controls">
|
|
||||||
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
|
||||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
|
||||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 9 9">
|
|
||||||
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<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;
|
|
||||||
}
|
|
||||||
/* Spacer to clear the native macOS traffic lights (~70px) */
|
|
||||||
.mac-spacer {
|
|
||||||
width: 70px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
button:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.close:hover { color: #fff; background: #c0392b; }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.toaster {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--sp-5);
|
||||||
|
right: var(--sp-5);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
||||||
|
pointer-events: all;
|
||||||
|
animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kind variants */
|
||||||
|
.toast_success { border-color: var(--accent-dim); }
|
||||||
|
.toast_success .toastIcon { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.toast_error { border-color: var(--color-error); }
|
||||||
|
.toast_error .toastIcon { color: var(--color-error); }
|
||||||
|
|
||||||
|
.toast_download .toastIcon { color: var(--accent-fg); }
|
||||||
|
.toast_info .toastIcon { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.toastIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastBody {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastTitle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastSub {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastClose {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.toastClose:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { store, dismissToast } from "../../store/state.svelte";
|
|
||||||
import type { Toast } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
function schedule(t: Toast) {
|
|
||||||
if (timers.has(t.id)) return;
|
|
||||||
const dur = t.duration ?? 3500;
|
|
||||||
if (dur === 0) return;
|
|
||||||
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
store.toasts.forEach(schedule);
|
|
||||||
return () => timers.forEach(clearTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
const icons: Record<Toast["kind"], string> = {
|
|
||||||
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.toasts.length}
|
|
||||||
<div class="toaster" aria-live="polite">
|
|
||||||
{#each store.toasts as t (t.id)}
|
|
||||||
<div class="toast toast-{t.kind}" role="alert">
|
|
||||||
<span class="icon">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d={icons[t.kind]} />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div class="body">
|
|
||||||
<p class="title">{t.title}</p>
|
|
||||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
|
||||||
</div>
|
|
||||||
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toaster {
|
|
||||||
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
|
|
||||||
z-index: 9999; display: flex; flex-direction: column;
|
|
||||||
gap: var(--sp-2); pointer-events: none; max-width: 320px;
|
|
||||||
}
|
|
||||||
.toast {
|
|
||||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
|
||||||
pointer-events: all; min-width: 220px;
|
|
||||||
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
|
||||||
}
|
|
||||||
@keyframes toastIn {
|
|
||||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
|
||||||
}
|
|
||||||
.toast-success { border-color: var(--accent-dim); }
|
|
||||||
.toast-success .icon { color: var(--accent-fg); }
|
|
||||||
.toast-error { border-color: var(--color-error); }
|
|
||||||
.toast-error .icon { color: var(--color-error); }
|
|
||||||
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
|
|
||||||
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
|
|
||||||
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
|
|
||||||
.sub {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.close {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 18px; height: 18px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { CheckCircle, X, WarningCircle, Info, DownloadSimple } from "@phosphor-icons/react";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import s from "./Toaster.module.css";
|
||||||
|
|
||||||
|
export type ToastKind = "success" | "error" | "info" | "download";
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
kind: ToastKind;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
duration?: number; // ms, 0 = persistent
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── icons per kind ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ToastIcon({ kind }: { kind: ToastKind }) {
|
||||||
|
const size = 15;
|
||||||
|
const w = "light" as const;
|
||||||
|
if (kind === "success") return <CheckCircle size={size} weight={w} />;
|
||||||
|
if (kind === "error") return <WarningCircle size={size} weight={w} />;
|
||||||
|
if (kind === "download") return <DownloadSimple size={size} weight={w} />;
|
||||||
|
return <Info size={size} weight={w} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── individual toast ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ToastItem({ toast }: { toast: Toast }) {
|
||||||
|
const dismissToast = useStore((s) => s.dismissToast);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const duration = toast.duration ?? 3500;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration === 0) return;
|
||||||
|
timerRef.current = setTimeout(() => dismissToast(toast.id), duration);
|
||||||
|
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||||
|
}, [toast.id, duration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[s.toast, s[`toast_${toast.kind}`]].join(" ")} role="alert">
|
||||||
|
<span className={s.toastIcon}><ToastIcon kind={toast.kind} /></span>
|
||||||
|
<div className={s.toastBody}>
|
||||||
|
<p className={s.toastTitle}>{toast.title}</p>
|
||||||
|
{toast.body && <p className={s.toastSub}>{toast.body}</p>}
|
||||||
|
</div>
|
||||||
|
<button className={s.toastClose} onClick={() => dismissToast(toast.id)} title="Dismiss">
|
||||||
|
<X size={12} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── toaster container ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Toaster() {
|
||||||
|
const toasts = useStore((s) => s.toasts);
|
||||||
|
|
||||||
|
if (!toasts.length) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={s.toaster} aria-live="polite">
|
||||||
|
{toasts.map((t) => <ToastItem key={t.id} toast={t} />)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy } from "svelte";
|
|
||||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } 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, clearDiscoverCache } 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";
|
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────
|
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
|
||||||
const GRID_LIMIT = 200;
|
|
||||||
const CONCURRENCY = 6;
|
|
||||||
const PAGES_INIT = 3; // pages per source on All tab
|
|
||||||
const PAGES_GENRE = 2; // pages per source on genre tabs
|
|
||||||
|
|
||||||
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 } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function dKey(srcId: string, type: string, genre: string, page: number) {
|
|
||||||
return `${srcId}|${type}|${genre}:p${page}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Local component state ─────────────────────────────────────────────────
|
|
||||||
let allSources: Source[] = $state([]);
|
|
||||||
let loadingLib = $state(true);
|
|
||||||
let loadError = $state(false);
|
|
||||||
let currentGenre = $state("All");
|
|
||||||
let genreResults = $state(new Map<string, Manga[]>());
|
|
||||||
let genreLoading = $state(false);
|
|
||||||
let refreshing = $state(false);
|
|
||||||
|
|
||||||
let activeCtrl: AbortController | null = null;
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
|
||||||
|
|
||||||
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
|
||||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
function dedup(items: Manga[]): Manga[] {
|
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterOut(mangas: Manga[]): Manga[] {
|
|
||||||
return dedup(mangas.filter(m => !m.inLibrary && !store.discoverLibraryIds.has(m.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotatedSources(): Source[] {
|
|
||||||
const lang = store.settings.preferredExtensionLang || "en";
|
|
||||||
const srcs = dedupeSources(allSources.filter(s => s.id !== "0"), lang);
|
|
||||||
if (!srcs.length) return [];
|
|
||||||
const off = store.discoverSrcOffset % srcs.length;
|
|
||||||
return [...srcs.slice(off), ...srcs.slice(0, off)];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push results into the reactive grid immediately — no batch delay.
|
|
||||||
function pushToGrid(genre: string, incoming: Manga[]) {
|
|
||||||
const filtered = filterOut(incoming);
|
|
||||||
if (!filtered.length) return;
|
|
||||||
const cur = genreResults.get(genre) ?? [];
|
|
||||||
genreResults.set(genre, dedup([...cur, ...filtered]).slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source fan-out ────────────────────────────────────────────────────────
|
|
||||||
async function fanOut(genre: string, ctrl: AbortController) {
|
|
||||||
const srcs = rotatedSources();
|
|
||||||
if (!srcs.length) return;
|
|
||||||
|
|
||||||
const isAll = genre === "All";
|
|
||||||
const type = isAll ? "POPULAR" : "SEARCH";
|
|
||||||
const query = isAll ? null : genre;
|
|
||||||
const maxPages = isAll ? PAGES_INIT : PAGES_GENRE;
|
|
||||||
|
|
||||||
await runConcurrent(srcs, async src => {
|
|
||||||
for (let page = 1; page <= maxPages; page++) {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const key = dKey(src.id, type, genre, page);
|
|
||||||
let mangas: Manga[];
|
|
||||||
let hasNextPage = false;
|
|
||||||
|
|
||||||
if (store.discoverCache.has(key)) {
|
|
||||||
// Cache hit — no network call needed
|
|
||||||
mangas = store.discoverCache.get(key)!;
|
|
||||||
} else {
|
|
||||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
|
||||||
FETCH_SOURCE_MANGA,
|
|
||||||
{ source: src.id, type, page, query },
|
|
||||||
ctrl.signal
|
|
||||||
).then(d => d.fetchSourceManga).catch(() => null);
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
|
||||||
mangas = result.mangas;
|
|
||||||
hasNextPage = result.hasNextPage;
|
|
||||||
store.discoverCache.set(key, mangas);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
if (isAll) {
|
|
||||||
pushToGrid("All", mangas);
|
|
||||||
} else {
|
|
||||||
const matching = mangas.filter(m =>
|
|
||||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
|
||||||
);
|
|
||||||
pushToGrid(genre, matching.length ? matching : mangas);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop paging early if source is exhausted
|
|
||||||
if (!hasNextPage) return;
|
|
||||||
}
|
|
||||||
}, ctrl.signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tab switch ────────────────────────────────────────────────────────────
|
|
||||||
async function switchGenre(genre: string) {
|
|
||||||
if (currentGenre === genre) return;
|
|
||||||
|
|
||||||
activeCtrl?.abort();
|
|
||||||
currentGenre = genre;
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
activeCtrl = ctrl;
|
|
||||||
|
|
||||||
if (genre === "All") {
|
|
||||||
// Already have results from this session — show instantly, re-fan in background
|
|
||||||
if ((genreResults.get("All") ?? []).length > 0) {
|
|
||||||
genreLoading = false;
|
|
||||||
fanOut("All", ctrl).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
genreResults.set("All", []);
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
genreLoading = true;
|
|
||||||
await fanOut("All", ctrl);
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Genre tab: serve cached local results instantly, always fan out too
|
|
||||||
const localKey = `local|${genre}`;
|
|
||||||
if (store.discoverCache.has(localKey)) {
|
|
||||||
genreResults.set(genre, store.discoverCache.get(localKey)!);
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
fanOut(genre, ctrl).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
genreLoading = true;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ mangas: { nodes: Manga[] } }>(
|
|
||||||
MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal
|
|
||||||
);
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const local = dedup(d.mangas.nodes);
|
|
||||||
store.discoverCache.set(localKey, local);
|
|
||||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
genreLoading = false;
|
|
||||||
|
|
||||||
fanOut(genre, ctrl).catch(() => {});
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Refresh ───────────────────────────────────────────────────────────────
|
|
||||||
async function refresh() {
|
|
||||||
activeCtrl?.abort();
|
|
||||||
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset
|
|
||||||
genreResults = new Map();
|
|
||||||
refreshing = true;
|
|
||||||
genreLoading = true;
|
|
||||||
const genre = currentGenre;
|
|
||||||
currentGenre = "";
|
|
||||||
await new Promise(r => setTimeout(r, 20));
|
|
||||||
await switchGenre(genre);
|
|
||||||
refreshing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Initial load ──────────────────────────────────────────────────────────
|
|
||||||
function loadAll() {
|
|
||||||
loadingLib = true;
|
|
||||||
loadError = false;
|
|
||||||
|
|
||||||
// Already have a session grid — show it immediately
|
|
||||||
if ((genreResults.get("All") ?? []).length > 0) {
|
|
||||||
loadingLib = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh library ID set so newly-added manga get filtered out
|
|
||||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
|
||||||
).then(m => {
|
|
||||||
store.discoverLibraryIds = new Set(
|
|
||||||
dedupeMangaById(m).filter(x => x.inLibrary).map(x => x.id)
|
|
||||||
);
|
|
||||||
}).catch(e => { console.error(e); loadError = true; })
|
|
||||||
.finally(() => { loadingLib = false; });
|
|
||||||
|
|
||||||
// Load sources then kick off All tab fan-out (only if grid is empty)
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then(d => {
|
|
||||||
allSources = d.sources.nodes;
|
|
||||||
if ((currentGenre === "All" || currentGenre === "") &&
|
|
||||||
(genreResults.get("All") ?? []).length === 0) {
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
activeCtrl = ctrl;
|
|
||||||
genreLoading = true;
|
|
||||||
fanOut("All", ctrl).then(() => {
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => { activeCtrl?.abort(); });
|
|
||||||
|
|
||||||
loadAll();
|
|
||||||
|
|
||||||
// ── 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);
|
|
||||||
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
|
||||||
}).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); }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.activeSource}
|
|
||||||
<SourceBrowse />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<button class="refresh-btn" class:spinning={refreshing} onclick={refresh} title="Refresh results" disabled={refreshing}>
|
|
||||||
<ArrowsClockwise size={13} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="body">
|
|
||||||
{#if isLoading && visibleGrid.length === 0}
|
|
||||||
<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 { display: flex; align-items: center; gap: var(--sp-4); flex-shrink: 0; padding: var(--sp-3) var(--sp-4) var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); overflow-x: auto; scrollbar-width: none; }
|
|
||||||
.header::-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; }
|
|
||||||
.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); }
|
|
||||||
.refresh-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; margin-left: auto; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.refresh-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
|
||||||
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
|
|
||||||
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
|
||||||
.manga-card:hover .card-title { color: #fff; }
|
|
||||||
.manga-card:hover { 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; }
|
|
||||||
.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; }
|
|
||||||
.card-skeleton { padding: 0; }
|
|
||||||
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
.skeleton { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
|
|
||||||
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.85 } }
|
|
||||||
.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); }
|
|
||||||
.refresh-btn.spinning { opacity: 0.5; cursor: default; }
|
|
||||||
.refresh-btn.spinning :global(svg) { animation: spin 0.8s linear infinite; }
|
|
||||||
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
<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="content">
|
|
||||||
<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><!-- .content -->
|
|
||||||
</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-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.header-actions { display: flex; gap: var(--sp-2); }
|
|
||||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn: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); }
|
|
||||||
.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>
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
<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="tabs">
|
|
||||||
{#each FILTERS as f}
|
|
||||||
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
|
||||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search" bind:value={search} />
|
|
||||||
</div>
|
|
||||||
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
|
|
||||||
<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()} use:focusOnMount />
|
|
||||||
<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}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#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; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
|
||||||
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :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-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>
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
<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 = $state(true);
|
|
||||||
let loadingMore = $state(false);
|
|
||||||
let visibleCount = $state(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>
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
.root {
|
||||||
|
display: flex; flex-direction: column; height: 100%;
|
||||||
|
overflow: hidden; animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||||
|
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||||
|
.search {
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md); padding: 5px 28px 5px 26px;
|
||||||
|
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.search::placeholder { color: var(--text-faint); }
|
||||||
|
.search:focus { border-color: var(--border-strong); }
|
||||||
|
.searchClear {
|
||||||
|
position: absolute; right: 7px;
|
||||||
|
color: var(--text-faint); font-size: 14px; line-height: 1;
|
||||||
|
background: none; border: none; cursor: pointer; padding: 2px;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.searchClear:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.clearBtn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
|
||||||
|
.statsBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statVal {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statDivider {
|
||||||
|
width: 1px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
||||||
|
|
||||||
|
.group { margin-bottom: var(--sp-5); }
|
||||||
|
.groupLabel {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md);
|
||||||
|
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
.row:hover .playIcon { opacity: 1; }
|
||||||
|
|
||||||
|
/* Thumb with session count badge */
|
||||||
|
.thumbWrap { position: relative; flex-shrink: 0; }
|
||||||
|
.thumb {
|
||||||
|
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
||||||
|
object-fit: cover; display: block; background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
.sessionBadge {
|
||||||
|
position: absolute; bottom: -4px; right: -6px;
|
||||||
|
background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 1px 4px; border-radius: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||||
|
.mangaTitle {
|
||||||
|
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.chapterName {
|
||||||
|
font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1); min-width: 0;
|
||||||
|
}
|
||||||
|
.chapterRange {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
color: var(--text-muted); font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.rangeSep {
|
||||||
|
color: var(--text-faint); font-size: 10px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pageBadge {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
flex-shrink: 0; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.playIcon {
|
||||||
|
color: var(--text-faint); flex-shrink: 0;
|
||||||
|
opacity: 0; transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.emptyIcon { color: var(--text-faint); }
|
||||||
|
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||||
|
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "@phosphor-icons/react";
|
||||||
|
import { thumbUrl } from "../../lib/client";
|
||||||
|
import { useStore, type HistoryEntry } from "../../store";
|
||||||
|
import s from "./History.module.css";
|
||||||
|
|
||||||
|
// ── Time helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function timeAgo(ts: number): string {
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
const m = Math.floor(diff / 60000);
|
||||||
|
if (m < 1) return "Just now";
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 7) return `${d}d ago`;
|
||||||
|
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayLabel(ts: number): string {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
if (d.toDateString() === now.toDateString()) return "Today";
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(now.getDate() - 1);
|
||||||
|
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
||||||
|
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate reading time: ~8 seconds per page, counted from chapter entries
|
||||||
|
// Each unique chapter read ≈ pageCount pages (fallback 30 if unknown)
|
||||||
|
function formatReadTime(minutes: number): string {
|
||||||
|
if (minutes < 1) return "< 1 min";
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
if (m === 0) return `${h}h`;
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session grouping ──────────────────────────────────────────────────────────
|
||||||
|
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
||||||
|
|
||||||
|
export interface ReadingSession {
|
||||||
|
mangaId: number;
|
||||||
|
mangaTitle: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
latestChapterId: number;
|
||||||
|
latestChapterName: string;
|
||||||
|
latestPageNumber: number;
|
||||||
|
firstChapterName: string;
|
||||||
|
chapterCount: number;
|
||||||
|
readAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessions(entries: HistoryEntry[]): ReadingSession[] {
|
||||||
|
if (!entries.length) return [];
|
||||||
|
const sessions: ReadingSession[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < entries.length) {
|
||||||
|
const anchor = entries[i];
|
||||||
|
const group: HistoryEntry[] = [anchor];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < entries.length) {
|
||||||
|
const next = entries[j];
|
||||||
|
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
||||||
|
group.push(next);
|
||||||
|
j++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const latest = group[0];
|
||||||
|
const oldest = group[group.length - 1];
|
||||||
|
sessions.push({
|
||||||
|
mangaId: latest.mangaId,
|
||||||
|
mangaTitle: latest.mangaTitle,
|
||||||
|
thumbnailUrl: latest.thumbnailUrl,
|
||||||
|
latestChapterId: latest.chapterId,
|
||||||
|
latestChapterName: latest.chapterName,
|
||||||
|
latestPageNumber: latest.pageNumber,
|
||||||
|
firstChapterName: oldest.chapterName,
|
||||||
|
chapterCount: group.length,
|
||||||
|
readAt: latest.readAt,
|
||||||
|
});
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSessionsByDay(sessions: ReadingSession[]): { label: string; items: ReadingSession[] }[] {
|
||||||
|
const groups = new Map<string, ReadingSession[]>();
|
||||||
|
for (const sess of sessions) {
|
||||||
|
const label = dayLabel(sess.readAt);
|
||||||
|
if (!groups.has(label)) groups.set(label, []);
|
||||||
|
groups.get(label)!.push(sess);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function History() {
|
||||||
|
const history = useStore((s) => s.history);
|
||||||
|
const clearHistory = useStore((s) => s.clearHistory);
|
||||||
|
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||||
|
const openReader = useStore((s) => s.openReader);
|
||||||
|
const activeChapterList = useStore((s) => s.activeChapterList);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return history;
|
||||||
|
return history.filter(
|
||||||
|
(e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [history, search]);
|
||||||
|
|
||||||
|
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
||||||
|
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
||||||
|
|
||||||
|
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (!history.length) return null;
|
||||||
|
// Unique chapters read
|
||||||
|
const uniqueChapters = new Set(history.map((e) => e.chapterId)).size;
|
||||||
|
// Unique manga read
|
||||||
|
const uniqueManga = new Set(history.map((e) => e.mangaId)).size;
|
||||||
|
// Estimated read time: average ~45 pages/chapter at ~6s/page = ~4.5 min/chapter
|
||||||
|
const estimatedMinutes = Math.round(uniqueChapters * 4.5);
|
||||||
|
return { uniqueChapters, uniqueManga, estimatedMinutes };
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
function resumeReading(session: ReadingSession) {
|
||||||
|
// If the chapter list is available in store (user already visited this manga),
|
||||||
|
// open the reader directly for a snappier experience
|
||||||
|
const chapterInList = activeChapterList.find((c) => c.id === session.latestChapterId);
|
||||||
|
if (chapterInList && activeChapterList.length > 0) {
|
||||||
|
openReader(chapterInList, activeChapterList);
|
||||||
|
} else {
|
||||||
|
// Fall back to opening SeriesDetail — it will show the continue button
|
||||||
|
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.root}>
|
||||||
|
<div className={s.header}>
|
||||||
|
<h1 className={s.heading}>History</h1>
|
||||||
|
<div className={s.headerRight}>
|
||||||
|
<div className={s.searchWrap}>
|
||||||
|
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||||
|
<input className={s.search} placeholder="Search history…"
|
||||||
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
{search && (
|
||||||
|
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
||||||
|
<Trash size={14} weight="light" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className={s.statsBar}>
|
||||||
|
<span className={s.statItem}>
|
||||||
|
<span className={s.statVal}>{stats.uniqueChapters}</span>
|
||||||
|
<span className={s.statLabel}>chapters read</span>
|
||||||
|
</span>
|
||||||
|
<span className={s.statDivider} />
|
||||||
|
<span className={s.statItem}>
|
||||||
|
<span className={s.statVal}>{stats.uniqueManga}</span>
|
||||||
|
<span className={s.statLabel}>series</span>
|
||||||
|
</span>
|
||||||
|
<span className={s.statDivider} />
|
||||||
|
<span className={s.statItem}>
|
||||||
|
<span className={s.statVal}>{formatReadTime(stats.estimatedMinutes)}</span>
|
||||||
|
<span className={s.statLabel}>est. read time</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||||
|
<p className={s.emptyText}>No reading history yet</p>
|
||||||
|
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<Books size={28} weight="light" className={s.emptyIcon} />
|
||||||
|
<p className={s.emptyText}>No results for "{search}"</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.list}>
|
||||||
|
{groups.map(({ label, items }) => (
|
||||||
|
<div key={label} className={s.group}>
|
||||||
|
<p className={s.groupLabel}>{label}</p>
|
||||||
|
{items.map((session) => (
|
||||||
|
<button
|
||||||
|
key={`${session.latestChapterId}-${session.readAt}`}
|
||||||
|
className={s.row}
|
||||||
|
onClick={() => resumeReading(session)}
|
||||||
|
>
|
||||||
|
<div className={s.thumbWrap}>
|
||||||
|
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} className={s.thumb} />
|
||||||
|
{session.chapterCount > 1 && (
|
||||||
|
<span className={s.sessionBadge}>{session.chapterCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={s.info}>
|
||||||
|
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
||||||
|
<span className={s.chapterName}>
|
||||||
|
{session.chapterCount > 1 ? (
|
||||||
|
<span className={s.chapterRange}>
|
||||||
|
{session.firstChapterName}
|
||||||
|
<span className={s.rangeSep}>→</span>
|
||||||
|
{session.latestChapterName}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{session.latestChapterName}
|
||||||
|
{session.latestPageNumber > 1 && (
|
||||||
|
<span className={s.pageBadge}>p.{session.latestPageNumber}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={s.time}>{timeAgo(session.readAt)}</span>
|
||||||
|
<Play size={12} weight="fill" className={s.playIcon} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
<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 = Math.round(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(() => {
|
|
||||||
loadLibrary();
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadLibrary() {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-fetch library and reset hero chapters whenever the reader closes,
|
|
||||||
// so the hero reflects the latest-read chapter immediately.
|
|
||||||
$effect(() => {
|
|
||||||
const sessionId = store.readerSessionId;
|
|
||||||
if (sessionId === 0) return; // skip initial mount — onMount handles that
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
loadingLibrary = true;
|
|
||||||
heroChapters = [];
|
|
||||||
heroAllChapters = [];
|
|
||||||
heroChaptersFor = null;
|
|
||||||
loadLibrary();
|
|
||||||
});
|
|
||||||
|
|
||||||
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 = []; heroAllChapters = []; }
|
|
||||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
|
||||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
|
|
||||||
|
|
||||||
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 heroAllChapters: 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 = [];
|
|
||||||
heroAllChapters = [];
|
|
||||||
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);
|
|
||||||
heroAllChapters = all;
|
|
||||||
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 = []; heroAllChapters = []; }
|
|
||||||
finally { loadingHeroChapters = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let resuming = $state(false);
|
|
||||||
|
|
||||||
async function openChapter(chapter: Chapter) {
|
|
||||||
if (!heroMangaId) return;
|
|
||||||
resuming = true;
|
|
||||||
try {
|
|
||||||
let all = heroAllChapters;
|
|
||||||
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 = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
|
|
||||||
if (target && heroAllChapters.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, 6));
|
|
||||||
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" role="presentation"
|
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}
|
|
||||||
onkeydown={(e) => { if (e.key === "Escape") 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>
|
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unreadBadge {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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); }
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
{#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}
|
|
||||||
</div><!-- .content -->
|
|
||||||
{/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; display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); 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; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); gap: var(--sp-4); flex-wrap: wrap; flex-shrink: 0; }
|
|
||||||
.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>
|
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
||||||
|
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
|
import s from "./Library.module.css";
|
||||||
|
|
||||||
|
const CARD_MIN_W = 130;
|
||||||
|
const CARD_GAP = 16;
|
||||||
|
const ROW_HEIGHT = 260;
|
||||||
|
|
||||||
|
function FadeImg({ src, alt, className, objectFit }: { src: string; alt: string; className?: string; objectFit?: string }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src} alt={alt} className={className}
|
||||||
|
loading="lazy" decoding="async"
|
||||||
|
style={{ objectFit: (objectFit ?? "cover") as any, opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MangaCard = memo(function MangaCard({
|
||||||
|
manga, onClick, onContextMenu, cropCovers,
|
||||||
|
}: {
|
||||||
|
manga: Manga;
|
||||||
|
onClick: () => void;
|
||||||
|
onContextMenu: (e: React.MouseEvent) => void;
|
||||||
|
cropCovers: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<FadeImg
|
||||||
|
src={thumbUrl(manga.thumbnailUrl)}
|
||||||
|
alt={manga.title}
|
||||||
|
className={s.cover}
|
||||||
|
objectFit={cropCovers ? "cover" : "contain"}
|
||||||
|
/>
|
||||||
|
{!!manga.downloadCount && (
|
||||||
|
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
||||||
|
)}
|
||||||
|
{!!manga.unreadCount && (
|
||||||
|
<span className={s.unreadBadge}>{manga.unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={s.title}>{manga.title}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fetchLibrary() {
|
||||||
|
return cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((lib) => lib.mangas.nodes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Library() {
|
||||||
|
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
|
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||||
|
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||||
|
const settings = useStore((state) => state.settings);
|
||||||
|
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||||
|
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||||
|
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||||
|
const folders = useStore((state) => state.settings.folders);
|
||||||
|
const addFolder = useStore((state) => state.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
||||||
|
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
||||||
|
const activeChapter = useStore((state) => state.activeChapter);
|
||||||
|
|
||||||
|
|
||||||
|
const prevChapterRef = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const wasOpen = prevChapterRef.current !== null;
|
||||||
|
prevChapterRef.current = activeChapter?.id ?? null;
|
||||||
|
if (!wasOpen || activeChapter) return;
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
}, [activeChapter]);
|
||||||
|
|
||||||
|
const loadData = useCallback((showLoading = false) => {
|
||||||
|
if (showLoading) setLoading(true);
|
||||||
|
// Clear a previously failed cache entry so we actually retry the network call
|
||||||
|
if (!cache.has(CACHE_KEYS.LIBRARY)) {
|
||||||
|
// cache miss — fresh fetch, nothing to clear
|
||||||
|
}
|
||||||
|
fetchLibrary()
|
||||||
|
.then((nodes) => { setAllManga(nodes); setError(null); })
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initial load — delayed on first mount so the server has time to start.
|
||||||
|
// retryCount bumps force a re-run; manual retries clear the cache first.
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
loadData(false);
|
||||||
|
|
||||||
|
// Re-fetch when library cache is invalidated by other pages
|
||||||
|
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false));
|
||||||
|
return unsub;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [retryCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: 0 });
|
||||||
|
}, [libraryFilter, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
||||||
|
if (activeFolder && !activeFolder.showTab) setLibraryFilter("library");
|
||||||
|
}, [folders]);
|
||||||
|
|
||||||
|
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let items = allManga;
|
||||||
|
if (libraryFilter === "library") {
|
||||||
|
items = items.filter((m) => m.inLibrary);
|
||||||
|
} else if (libraryFilter === "downloaded") {
|
||||||
|
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
||||||
|
} else if (!isBuiltinFilter) {
|
||||||
|
const folder = folders.find((f) => f.id === libraryFilter);
|
||||||
|
if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id));
|
||||||
|
}
|
||||||
|
if (libraryTagFilter.length > 0)
|
||||||
|
items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag)));
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
||||||
|
|
||||||
|
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
||||||
|
const [containerWidth, setContainerWidth] = useState(800);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
setContainerWidth(entry.contentRect.width);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const result: Manga[][] = [];
|
||||||
|
for (let i = 0; i < filtered.length; i += cols)
|
||||||
|
result.push(filtered.slice(i, i + cols));
|
||||||
|
return result;
|
||||||
|
}, [filtered, cols]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => ROW_HEIGHT,
|
||||||
|
overscan: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(
|
||||||
|
(m: Manga) => () => setActiveManga(m),
|
||||||
|
[setActiveManga]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function removeFromLibrary(manga: Manga) {
|
||||||
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||||
|
// Optimistic update first, then invalidate cache
|
||||||
|
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAllDownloads(manga: Manga) {
|
||||||
|
try {
|
||||||
|
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
||||||
|
const downloadedChapters = data.chapters.nodes.filter((c) => c.isDownloaded);
|
||||||
|
const ids = downloadedChapters.map((c) => c.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||||
|
await Promise.allSettled(ids.map((id) => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
||||||
|
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault();
|
||||||
|
const x = Math.min(e.clientX, window.innerWidth - 208);
|
||||||
|
const y = Math.min(e.clientY, window.innerHeight - 168);
|
||||||
|
setCtx({ x, y, manga: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
|
const mangaFolderEntries: ContextMenuEntry[] = folders.map((f) => {
|
||||||
|
const inFolder = f.mangaIds.includes(m.id);
|
||||||
|
return {
|
||||||
|
label: inFolder ? `✓ ${f.name}` : f.name,
|
||||||
|
icon: <Folder size={13} weight={inFolder ? "fill" : "light"} />,
|
||||||
|
onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Open",
|
||||||
|
icon: <BookOpen size={13} weight="light" />,
|
||||||
|
onClick: () => setActiveManga(m),
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: m.inLibrary ? "Remove from library" : "Add to library",
|
||||||
|
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||||
|
danger: m.inLibrary,
|
||||||
|
onClick: () => m.inLibrary
|
||||||
|
? removeFromLibrary(m)
|
||||||
|
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
|
.then(() => {
|
||||||
|
setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
})
|
||||||
|
.catch(console.error),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete all downloads",
|
||||||
|
icon: <Trash size={13} weight="light" />,
|
||||||
|
danger: true,
|
||||||
|
disabled: !(m.downloadCount && m.downloadCount > 0),
|
||||||
|
onClick: () => deleteAllDownloads(m),
|
||||||
|
},
|
||||||
|
...(folders.length > 0 ? [
|
||||||
|
{ separator: true } as ContextMenuEntry,
|
||||||
|
...mangaFolderEntries,
|
||||||
|
] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "New folder",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) {
|
||||||
|
const id = addFolder(name.trim());
|
||||||
|
assignMangaToFolder(id, m.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmptyCtxItems(): ContextMenuEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "New folder",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) addFolder(name.trim());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTags = useMemo(() => {
|
||||||
|
const tagSet = new Set<string>();
|
||||||
|
allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g)));
|
||||||
|
return Array.from(tagSet).sort();
|
||||||
|
}, [allManga]);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const result: Record<string, number> = {
|
||||||
|
all: allManga.length,
|
||||||
|
library: allManga.filter((m) => m.inLibrary).length,
|
||||||
|
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
||||||
|
};
|
||||||
|
folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; });
|
||||||
|
return result;
|
||||||
|
}, [allManga, folders]);
|
||||||
|
|
||||||
|
if (error) return (
|
||||||
|
<div className={s.center}>
|
||||||
|
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
||||||
|
<p className={s.errorDetail}>Make sure the server is running, then retry.</p>
|
||||||
|
<button
|
||||||
|
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||||
|
onClick={() => setRetryCount((c) => c + 1)}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={s.root}
|
||||||
|
ref={scrollRef}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setEmptyCtx({ x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={s.header}>
|
||||||
|
<div className={s.headerLeft}>
|
||||||
|
<h1 className={s.heading}>Library</h1>
|
||||||
|
<div className={s.tabs}>
|
||||||
|
{(["library", "downloaded", "all"] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => setLibraryFilter(f)}
|
||||||
|
>
|
||||||
|
{f === "library" ? (
|
||||||
|
<><Books size={11} weight="bold" /> Saved</>
|
||||||
|
) : f === "downloaded" ? (
|
||||||
|
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
||||||
|
) : <>All</>}
|
||||||
|
<span className={s.tabCount}>{counts[f]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{folders.filter((f) => f.showTab).map((folder) => (
|
||||||
|
<button
|
||||||
|
key={folder.id}
|
||||||
|
className={[s.tab, libraryFilter === folder.id ? s.tabActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => setLibraryFilter(folder.id)}
|
||||||
|
>
|
||||||
|
<Folder size={11} weight="bold" />
|
||||||
|
{folder.name}
|
||||||
|
<span className={s.tabCount}>{counts[folder.id] ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={s.searchWrap}>
|
||||||
|
<MagnifyingGlass size={13} className={s.searchIcon} weight="light" />
|
||||||
|
<input
|
||||||
|
className={s.search}
|
||||||
|
placeholder="Search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allTags.length > 0 && (
|
||||||
|
<div className={s.tagPanel}>
|
||||||
|
{libraryTagFilter.length > 0 && (
|
||||||
|
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
||||||
|
<X size={11} weight="bold" /> Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{allTags.map((tag) => {
|
||||||
|
const active = libraryTagFilter.includes(tag);
|
||||||
|
return (
|
||||||
|
<button key={tag}
|
||||||
|
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
||||||
|
onClick={() => setGenreFilter(tag)}>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={s.grid}>
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i} className={s.cardSkeleton}>
|
||||||
|
<div className={[s.coverSkeletonWrap, "skeleton"].join(" ")} />
|
||||||
|
<div className={[s.titleSkeleton, "skeleton"].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className={s.center}>
|
||||||
|
{libraryFilter === "library"
|
||||||
|
? "No manga saved to library, browse sources to add some."
|
||||||
|
: libraryFilter === "downloaded"
|
||||||
|
? "No downloaded manga."
|
||||||
|
: !isBuiltinFilter
|
||||||
|
? "No manga in this folder yet. Right-click manga to assign them."
|
||||||
|
: "No manga found."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const rowManga = rows[virtualRow.index];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: virtualRow.start,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: virtualRow.size,
|
||||||
|
}}
|
||||||
|
className={s.virtualRow}
|
||||||
|
>
|
||||||
|
{rowManga.map((m) => (
|
||||||
|
<MangaCard
|
||||||
|
key={m.id}
|
||||||
|
manga={m}
|
||||||
|
onClick={handleCardClick(m)}
|
||||||
|
onContextMenu={(e) => openCtx(e, m)}
|
||||||
|
cropCovers={settings.libraryCropCovers}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{virtualRow.index === rows.length - 1 &&
|
||||||
|
Array.from({ length: cols - rowManga.length }).map((_, i) => (
|
||||||
|
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||||
|
)}
|
||||||
|
{emptyCtx && (
|
||||||
|
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtxItems()} onClose={() => setEmptyCtx(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
animation: fadeIn 0.1s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
width: 520px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--sp-4) var(--sp-5);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitleLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitleManga {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
/* ── Steps ── */
|
||||||
|
.steps {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
padding: var(--sp-3) var(--sp-5);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepActive { opacity: 1; }
|
||||||
|
.stepDone { opacity: 0.6; }
|
||||||
|
|
||||||
|
.stepDot {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepActive .stepDot {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepActive .stepLabel { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.steps .step + .step::before {
|
||||||
|
content: "›";
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-right: var(--sp-1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Source list ── */
|
||||||
|
.sourceList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--sp-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 9px var(--sp-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.sourceRow:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
.sourceRowActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.sourceIcon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceInfo { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||||
|
|
||||||
|
.sourceName {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceMeta {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceArrow {
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.sourceRow:hover .sourceArrow { opacity: 1; }
|
||||||
|
|
||||||
|
/* ── Search step ── */
|
||||||
|
.searchStep {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchBar {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0 var(--sp-3) 0 var(--sp-2);
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: 7px 0;
|
||||||
|
}
|
||||||
|
.searchInput::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.searchBtn {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
}
|
||||||
|
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.backBtn {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.backBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.backBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.results {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 7px var(--sp-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.resultRow:hover:not(:disabled) { background: var(--bg-raised); }
|
||||||
|
.resultRow:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.resultCoverWrap {
|
||||||
|
width: 36px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultCover { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
|
.resultTitle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeletons */
|
||||||
|
.skResult {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 7px var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skCover {
|
||||||
|
width: 36px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skMeta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.skTitle { height: 13px; width: 65%; border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
|
/* ── Confirm step ── */
|
||||||
|
.confirmStep {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
padding: var(--sp-4) var(--sp-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmManga {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex: 1;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmCoverWrap {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2/3;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmCover { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
|
.confirmTitle {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmSource {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmArrow { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.confirmStats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statVal {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmNote {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
line-height: var(--leading-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migrateBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.migrateBtn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||||
|
.migrateBtn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-error);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
||||||
|
}
|
||||||
|
/* ── Source context pill (step 2 header) ── */
|
||||||
|
.searchContext {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchContextIcon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchContextName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchContextChange {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.searchContextChange:hover { opacity: 0.75; }
|
||||||
|
|
||||||
|
/* ── Result row: updated layout with similarity ── */
|
||||||
|
.resultInfo {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bestMatchBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simBar {
|
||||||
|
width: 48px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simFill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Confirm step additions ── */
|
||||||
|
.confirmDivider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmTag {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmTagNew {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statGood { color: var(--color-success) !important; }
|
||||||
|
.statWarn { color: #d97706 !important; }
|
||||||
|
.statBad { color: var(--color-error) !important; }
|
||||||
|
|
||||||
|
.chapterDiff {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: #d97706;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
margin-left: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: rgba(217, 119, 6, 0.08);
|
||||||
|
border: 1px solid rgba(217, 119, 6, 0.25);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: #d97706;
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
|
||||||
import { untrack } from "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);
|
|
||||||
let query = $state(untrack(() => manga.title));
|
|
||||||
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…" use:focusOnMount />
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||||
|
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||||
|
import s from "./MigrateModal.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
manga: Manga;
|
||||||
|
currentChapters: Chapter[];
|
||||||
|
onClose: () => void;
|
||||||
|
onMigrated: (newManga: Manga) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = "source" | "search" | "confirm";
|
||||||
|
|
||||||
|
interface Match {
|
||||||
|
manga: Manga;
|
||||||
|
chapters: Chapter[];
|
||||||
|
readCount: number;
|
||||||
|
similarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple title similarity: normalise → word overlap / Jaccard
|
||||||
|
function titleSimilarity(a: string, b: string): number {
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||||
|
const wordsA = new Set(norm(a));
|
||||||
|
const wordsB = new Set(norm(b));
|
||||||
|
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||||
|
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
||||||
|
const union = new Set([...wordsA, ...wordsB]).size;
|
||||||
|
return intersection / union;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
|
||||||
|
const [step, setStep] = useState<Step>("source");
|
||||||
|
const [sources, setSources] = useState<Source[]>([]);
|
||||||
|
const [loadingSources, setLoadingSources] = useState(true);
|
||||||
|
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
||||||
|
const [query, setQuery] = useState(manga.title);
|
||||||
|
const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||||
|
const [loadingMatchId, setLoadingMatchId] = useState<number | null>(null);
|
||||||
|
const [migrating, setMigrating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => setSources(d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id)))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoadingSources(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchSource = useCallback(async (src: Source, q: string) => {
|
||||||
|
if (!src || !q.trim()) return;
|
||||||
|
setSearching(true);
|
||||||
|
setResults([]);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
|
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
|
||||||
|
});
|
||||||
|
const scored = d.fetchSourceManga.mangas.map((m) => ({
|
||||||
|
manga: m,
|
||||||
|
similarity: titleSimilarity(manga.title, m.title),
|
||||||
|
}));
|
||||||
|
// Sort by similarity desc so best matches float to top
|
||||||
|
scored.sort((a, b) => b.similarity - a.similarity);
|
||||||
|
setResults(scored);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, [manga.title]);
|
||||||
|
|
||||||
|
function pickSource(src: Source) {
|
||||||
|
setSelectedSource(src);
|
||||||
|
setStep("search");
|
||||||
|
// Auto-search immediately with original title
|
||||||
|
searchSource(src, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectMatch(m: Manga, similarity: number) {
|
||||||
|
setLoadingMatchId(m.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
||||||
|
const chapters = d.fetchChapters.chapters;
|
||||||
|
const readCount = chapters.filter((c) => {
|
||||||
|
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
||||||
|
return old?.isRead;
|
||||||
|
}).length;
|
||||||
|
setSelectedMatch({ manga: m, chapters, readCount, similarity });
|
||||||
|
setStep("confirm");
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoadingMatchId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
if (!selectedMatch) return;
|
||||||
|
setMigrating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
||||||
|
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
||||||
|
|
||||||
|
const toMarkRead: number[] = [];
|
||||||
|
const toMarkBookmarked: number[] = [];
|
||||||
|
const progressUpdates: { id: number; lastPageRead: number }[] = [];
|
||||||
|
|
||||||
|
for (const nc of newChapters) {
|
||||||
|
const key = Math.round(nc.chapterNumber * 100);
|
||||||
|
const old = oldByNum.get(key);
|
||||||
|
if (!old) continue;
|
||||||
|
if (old.isRead) toMarkRead.push(nc.id);
|
||||||
|
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||||
|
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
||||||
|
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toMarkRead.length)
|
||||||
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||||
|
if (toMarkBookmarked.length)
|
||||||
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||||
|
for (const { id, lastPageRead } of progressUpdates)
|
||||||
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
||||||
|
|
||||||
|
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||||
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
||||||
|
|
||||||
|
onMigrated({ ...newManga, inLibrary: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setMigrating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readCount = currentChapters.filter((c) => c.isRead).length;
|
||||||
|
const totalCount = currentChapters.length;
|
||||||
|
|
||||||
|
const chapterDiff = selectedMatch
|
||||||
|
? selectedMatch.chapters.length - totalCount
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const STEPS: Step[] = ["source", "search", "confirm"];
|
||||||
|
const stepIdx = STEPS.indexOf(step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className={s.modal}>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className={s.modalHeader}>
|
||||||
|
<div className={s.modalTitle}>
|
||||||
|
<span className={s.modalTitleLabel}>Migrate source</span>
|
||||||
|
<span className={s.modalTitleManga}>{manga.title}</span>
|
||||||
|
</div>
|
||||||
|
<button className={s.closeBtn} onClick={onClose}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Step indicators ── */}
|
||||||
|
<div className={s.steps}>
|
||||||
|
{STEPS.map((st, i) => (
|
||||||
|
<div key={st}
|
||||||
|
className={[s.step, step === st ? s.stepActive : "", i < stepIdx ? s.stepDone : ""].join(" ").trim()}>
|
||||||
|
<span className={s.stepDot}>
|
||||||
|
{i < stepIdx ? <Check size={9} weight="bold" /> : i + 1}
|
||||||
|
</span>
|
||||||
|
<span className={s.stepLabel}>
|
||||||
|
{st === "source" ? "Pick source"
|
||||||
|
: st === "search" ? (selectedSource ? selectedSource.displayName : "Search")
|
||||||
|
: "Confirm"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.body}>
|
||||||
|
|
||||||
|
{/* ── Step 1: Pick source ── */}
|
||||||
|
{step === "source" && (
|
||||||
|
<div className={s.sourceList}>
|
||||||
|
{loadingSources ? (
|
||||||
|
<div className={s.centered}>
|
||||||
|
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
</div>
|
||||||
|
) : sources.length === 0 ? (
|
||||||
|
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
||||||
|
) : (
|
||||||
|
sources.map((src) => (
|
||||||
|
<button key={src.id}
|
||||||
|
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => pickSource(src)}>
|
||||||
|
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<div className={s.sourceInfo}>
|
||||||
|
<span className={s.sourceName}>{src.displayName}</span>
|
||||||
|
<span className={s.sourceMeta}>{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={13} weight="light" className={s.sourceArrow} />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Search & pick match ── */}
|
||||||
|
{step === "search" && (
|
||||||
|
<div className={s.searchStep}>
|
||||||
|
|
||||||
|
{/* Source context pill */}
|
||||||
|
{selectedSource && (
|
||||||
|
<div className={s.searchContext}>
|
||||||
|
<img src={thumbUrl(selectedSource.iconUrl)} alt="" className={s.searchContextIcon}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span className={s.searchContextName}>{selectedSource.displayName}</span>
|
||||||
|
<button className={s.searchContextChange} onClick={() => { setStep("source"); setResults([]); }}>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={s.searchRow}>
|
||||||
|
<div className={s.searchBar}>
|
||||||
|
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
||||||
|
<input className={s.searchInput} value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||||
|
placeholder="Search title…"
|
||||||
|
autoFocus />
|
||||||
|
</div>
|
||||||
|
<button className={s.searchBtn}
|
||||||
|
onClick={() => selectedSource && searchSource(selectedSource, query)}
|
||||||
|
disabled={searching || !selectedSource}>
|
||||||
|
{searching
|
||||||
|
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||||
|
: <><MagnifyingGlass size={12} weight="bold" /> Search</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
||||||
|
|
||||||
|
<div className={s.results}>
|
||||||
|
{searching && Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className={s.skResult}>
|
||||||
|
<div className={["skeleton", s.skCover].join(" ")} />
|
||||||
|
<div className={s.skMeta}>
|
||||||
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
|
<div className={["skeleton", s.skTitle].join(" ")} style={{ width: "40%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!searching && results.map(({ manga: m, similarity }, idx) => (
|
||||||
|
<button key={m.id} className={s.resultRow}
|
||||||
|
onClick={() => selectMatch(m, similarity)}
|
||||||
|
disabled={loadingMatchId !== null}>
|
||||||
|
<div className={s.resultCoverWrap}>
|
||||||
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
||||||
|
</div>
|
||||||
|
<div className={s.resultInfo}>
|
||||||
|
<span className={s.resultTitle}>{m.title}</span>
|
||||||
|
<div className={s.resultMeta}>
|
||||||
|
{idx === 0 && similarity > 0.5 && (
|
||||||
|
<span className={s.bestMatchBadge}>
|
||||||
|
<Sparkle size={9} weight="fill" /> Best match
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={s.simBar}>
|
||||||
|
<span className={s.simFill} style={{ width: `${Math.round(similarity * 100)}%` }} />
|
||||||
|
</span>
|
||||||
|
<span className={s.simLabel}>{Math.round(similarity * 100)}% match</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loadingMatchId === m.id
|
||||||
|
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
|
||||||
|
: <ArrowRight size={13} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0, opacity: 0.5 }} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!searching && results.length === 0 && !error && (
|
||||||
|
<div className={s.centered}>
|
||||||
|
<span className={s.hint}>{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 3: Confirm ── */}
|
||||||
|
{step === "confirm" && selectedMatch && (
|
||||||
|
<div className={s.confirmStep}>
|
||||||
|
<div className={s.confirmRow}>
|
||||||
|
<div className={s.confirmManga}>
|
||||||
|
<div className={s.confirmCoverWrap}>
|
||||||
|
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.confirmCover} />
|
||||||
|
</div>
|
||||||
|
<p className={s.confirmTitle}>{manga.title}</p>
|
||||||
|
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
||||||
|
<span className={s.confirmTag}>Current</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.confirmDivider}>
|
||||||
|
<ArrowRight size={16} weight="light" className={s.confirmArrow} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.confirmManga}>
|
||||||
|
<div className={s.confirmCoverWrap}>
|
||||||
|
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} className={s.confirmCover} />
|
||||||
|
</div>
|
||||||
|
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
||||||
|
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
||||||
|
<span className={[s.confirmTag, s.confirmTagNew].join(" ")}>New</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.confirmStats}>
|
||||||
|
<div className={s.statRow}>
|
||||||
|
<span className={s.statLabel}>Title match</span>
|
||||||
|
<span className={[s.statVal, selectedMatch.similarity > 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}>
|
||||||
|
{Math.round(selectedMatch.similarity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={s.statRow}>
|
||||||
|
<span className={s.statLabel}>Chapters on new source</span>
|
||||||
|
<span className={[s.statVal, chapterDiff < -5 ? s.statWarn : ""].join(" ").trim()}>
|
||||||
|
{selectedMatch.chapters.length}
|
||||||
|
{chapterDiff !== 0 && (
|
||||||
|
<span className={s.chapterDiff}>{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={s.statRow}>
|
||||||
|
<span className={s.statLabel}>Read progress to carry over</span>
|
||||||
|
<span className={s.statVal}>{selectedMatch.readCount} / {readCount} chapters</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chapterDiff < -5 && (
|
||||||
|
<div className={s.warnBox}>
|
||||||
|
<Warning size={13} weight="light" />
|
||||||
|
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className={s.confirmNote}>
|
||||||
|
The current entry will be removed from your library. Downloads are not transferred.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
||||||
|
|
||||||
|
<div className={s.confirmActions}>
|
||||||
|
<button className={s.backBtn} onClick={() => setStep("search")} disabled={migrating}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
||||||
|
{migrating
|
||||||
|
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
||||||
|
: <><Check size={13} weight="bold" /> Migrate</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||