Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b6d0780c9 | |||
| 6ef0facb89 | |||
| 34d997fc9d | |||
| 1f08b46919 | |||
| ac6b70fb32 | |||
| 2c93d8743d | |||
| b9fe54c08d | |||
| 3abb4bb96c | |||
| 4b3493465d | |||
| 2163f4a8a6 | |||
| fc535f3f74 | |||
| c819d03222 | |||
| b23292cff5 | |||
| 6d85be751a | |||
| 06a9e71a90 | |||
| 1a183e7a24 | |||
| dcb3377349 | |||
| 077ea4dd8f | |||
| 6bdf59db6a | |||
| db9ff33c64 | |||
| fb1b3d9789 | |||
| 041f735a6e | |||
| a27c20fabf | |||
| 29323c534b | |||
| a3ef693ed8 | |||
| 4691f3aed7 | |||
| 06cb70048b | |||
| d3e62a7a08 | |||
| b6ef2b1b3c | |||
| c13a4eb77a | |||
| bd972eccf3 | |||
| 9610c0294d | |||
| 406819ccca | |||
| 272e026210 | |||
| 57bf9d5fb1 | |||
| 7df7191799 | |||
| e6b542cd6b | |||
| 4903b066b1 | |||
| 96bac1ad2b | |||
| 94b92d000f | |||
| 43630ef72d | |||
| 161b1f9f52 | |||
| 816b384d64 | |||
| b772b94c6c | |||
| deb8a5ee02 | |||
| 821e13fc44 | |||
| 937054d674 | |||
| 4532b37201 | |||
| 73b73e85d7 | |||
| 697116b630 | |||
| 0e87c51801 | |||
| bf38e00cf3 | |||
| eb7360ee05 | |||
| c9eba3da86 | |||
| fc68d3ac7e | |||
| 1fa1c3a2e0 | |||
| 8c38330143 | |||
| 272d7673ce | |||
| 3d074a1fb1 | |||
| be15cb6ad8 | |||
| 3aee69939b | |||
| 0557f3f2d6 | |||
| 817af0d10a | |||
| 70afb08f83 | |||
| f751f34c68 | |||
| 8c9d3fc783 | |||
| 0f0cd87e6d | |||
| f5a1b13e43 | |||
| 4fca379715 | |||
| ac5e3ae53b | |||
| 6d39d5574a | |||
| 5e8f0d2f52 | |||
| 87e2009d4e | |||
| 2f5103c48c | |||
| 9d9c1b61e7 | |||
| a1a0f360d7 | |||
| 9a0afed2b0 | |||
| 28e9e3bcf8 | |||
| ac04c39ead | |||
| 7d3d76fa6d | |||
| fec0e5d3f6 | |||
| f866d4d0e9 | |||
| ac1c0520c5 | |||
| fff6bde8ad | |||
| c07fc90fc8 | |||
| 523fb40538 | |||
| fb82abaf21 | |||
| 0a4108218d | |||
| 7b61f85833 | |||
| cd2d79f80c | |||
| edf2af8618 |
@@ -1,66 +0,0 @@
|
|||||||
name: Build AppImage
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version tag (e.g. 0.1.0)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
# ubuntu-20.04 ships webkit2gtk 2.44 by default, avoiding the
|
|
||||||
# EGL_BAD_PARAMETER crash present in 2.46+
|
|
||||||
# https://github.com/gitbutlerapp/gitbutler/issues/5282
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libsoup-3.0-dev \
|
|
||||||
patchelf \
|
|
||||||
file
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Setup Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Build AppImage
|
|
||||||
run: pnpm tauri build --bundles appimage
|
|
||||||
env:
|
|
||||||
NO_STRIP: "true"
|
|
||||||
|
|
||||||
- name: Upload AppImage
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Moku-${{ github.event.inputs.version || github.sha }}-amd64.AppImage
|
|
||||||
path: src-tauri/target/release/bundle/appimage/*.AppImage
|
|
||||||
if-no-files-found: error
|
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
name: Build macOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.4.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (macOS)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: macos-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi binaries
|
||||||
|
run: |
|
||||||
|
download_suwayomi() {
|
||||||
|
local asset="$1" sha="$2" outdir="$3"
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
|
||||||
|
-o "${outdir}.tar.gz"
|
||||||
|
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||||
|
mkdir -p "${outdir}"
|
||||||
|
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
||||||
|
}
|
||||||
|
|
||||||
|
download_suwayomi \
|
||||||
|
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
|
||||||
|
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
|
||||||
|
"suwayomi-arm64"
|
||||||
|
|
||||||
|
download_suwayomi \
|
||||||
|
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
|
||||||
|
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
|
||||||
|
"suwayomi-x64"
|
||||||
|
|
||||||
|
- name: Stage Suwayomi sidecars
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
|
||||||
|
stage_arch() {
|
||||||
|
local srcdir="$1"
|
||||||
|
local arch="$2"
|
||||||
|
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
|
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||||
|
|
||||||
|
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||||
|
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$JAR" ]; then
|
||||||
|
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
||||||
|
find "$srcdir" -type f | head -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$JAVA" ]; then
|
||||||
|
echo "ERROR: jre/bin/java not found in $srcdir"
|
||||||
|
find "$srcdir" -type f | head -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${arch}: jar=${JAR} java=${JAVA}"
|
||||||
|
|
||||||
|
cp -r "$srcdir" "$bundle_dest"
|
||||||
|
|
||||||
|
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
|
||||||
|
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
|
||||||
|
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
|
||||||
|
chmod +x "$sidecar"
|
||||||
|
echo "Staged sidecar: $sidecar"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
||||||
|
stage_arch suwayomi-x64 x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
run: |
|
||||||
|
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
# ── aarch64 build ──────────────────────────────────────────────────────
|
||||||
|
- name: Swap bundle for aarch64
|
||||||
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
||||||
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (aarch64)
|
||||||
|
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
|
env:
|
||||||
|
# Ad-hoc signing ("-") ships without a Developer ID.
|
||||||
|
# Gatekeeper will quarantine the app on other Macs — users must run:
|
||||||
|
# xattr -rd com.apple.quarantine Moku.app
|
||||||
|
# To fix this properly, set APPLE_SIGNING_IDENTITY to your
|
||||||
|
# "Developer ID Application: ..." cert name and add
|
||||||
|
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID /
|
||||||
|
# APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD secrets for notarisation.
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
|
# ── x86_64 build ───────────────────────────────────────────────────────
|
||||||
|
- name: Swap bundle for x86_64
|
||||||
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
||||||
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (x86_64)
|
||||||
|
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
|
# ── upload artifacts ───────────────────────────────────────────────────
|
||||||
|
- name: Upload arm64 .dmg
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: moku-macos-arm64-${{ github.event.inputs.version }}
|
||||||
|
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload x64 .dmg
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: moku-macos-x64-${{ github.event.inputs.version }}
|
||||||
|
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.4.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-windows
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (Windows x64)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-windows
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi (Windows x64)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
||||||
|
-o suwayomi-windows.zip
|
||||||
|
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
||||||
|
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||||
|
|
||||||
|
- name: Extract Suwayomi bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p suwayomi-extracted
|
||||||
|
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||||
|
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||||
|
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||||
|
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
|
||||||
|
cp -r "$INNER"/. suwayomi-extracted/
|
||||||
|
else
|
||||||
|
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Stage Suwayomi bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||||
|
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||||
|
if [ -z "$JAVA" ]; then
|
||||||
|
echo "ERROR: jre/bin/java.exe not found"
|
||||||
|
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
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
|
||||||
|
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
|
||||||
|
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
|
||||||
|
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
|
||||||
|
echo "Staging OK"
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Delete existing draft release if present
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
echo "Deleting existing draft release $RELEASE_ID"
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID"
|
||||||
|
# Also delete the tag so tauri-action can recreate it
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||||
|
echo "Deleted draft release and tag"
|
||||||
|
else
|
||||||
|
echo "No existing draft release found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Tauri app + create draft release
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
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:
|
||||||
|
tagName: v${{ github.event.inputs.version }}
|
||||||
|
releaseName: Moku v${{ github.event.inputs.version }}
|
||||||
|
releaseBody: |
|
||||||
|
Windows installer for Moku v${{ github.event.inputs.version }}.
|
||||||
|
Download the `.exe` file below to install or update.
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
||||||
|
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
@@ -37,5 +37,7 @@ src-tauri/gen/
|
|||||||
# --- Flatpak build artifacts ---
|
# --- Flatpak build artifacts ---
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
|
dist/
|
||||||
|
packaging/frontend-dist.tar.gz
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/
|
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.1.0
|
pkgver=0.5.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/shozikan/Moku"
|
url="https://github.com/Youwes09/Moku"
|
||||||
license=('MIT')
|
license=('Apache 2.0')
|
||||||
depends=(
|
depends=(
|
||||||
'webkit2gtk-4.1'
|
'webkit2gtk-4.1'
|
||||||
'gtk3'
|
'gtk3'
|
||||||
@@ -18,15 +18,13 @@ makedepends=(
|
|||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
source=(
|
source=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/shozikan/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
||||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
||||||
)
|
)
|
||||||
sha256sums=(
|
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
||||||
'0019dfc4b32d63c1392aa264aed2253c1e0c2fb09216f8e2cc269bbfb8bb49b5'
|
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d'
|
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||||
)
|
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
@@ -35,14 +33,8 @@ 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
|
||||||
@@ -51,19 +43,15 @@ 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"
|
||||||
@@ -111,7 +99,6 @@ 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,137 +1,140 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="src/assets/rounded-logo.png" width="96" />
|
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||||
<h1>Moku</h1>
|
</div>
|
||||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
|
|
||||||
|
|
||||||
<table>
|
<div align="center">
|
||||||
<tr>
|
|
||||||
<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>
|
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||||
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
|
[](./LICENSE)
|
||||||
</tr>
|
[](https://discord.gg/cfncTbJ2)
|
||||||
<tr>
|
|
||||||
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td>
|
</div>
|
||||||
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
|
|
||||||
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td>
|
<br/>
|
||||||
</tr>
|
|
||||||
</table>
|
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
||||||
|
<img src="docs/screenshots/Moku-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
|
||||||
|
|
||||||
### Reader
|
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||||
- **Single**, **double-page**, and **longstrip** reading modes
|
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||||
- **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
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
- Fit modes: fit width, fit height, fit screen, and 1:1 original
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
- Per-series zoom control via Ctrl+scroll or a slider popover
|
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
||||||
- RTL / LTR reading direction toggle
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
- Configurable page gaps
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
- Full keyboard navigation with rebindable keybinds
|
- **Auto-updates** — in-app update checker with silent background notifications
|
||||||
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
|
|
||||||
- Chapter-relative page counter that updates live as you scroll through the infinite strip
|
|
||||||
- Auto-mark chapters as read when the last page is reached
|
|
||||||
|
|
||||||
### Library
|
|
||||||
- Grid view of your entire manga collection with lazy-loaded cover art
|
|
||||||
- Filter tabs: **Saved**, **Downloaded**, and **All**
|
|
||||||
- Genre tag filter chips — multi-select to narrow by any combination of tags
|
|
||||||
- In-line search
|
|
||||||
- Context menu: open, add/remove from library
|
|
||||||
|
|
||||||
### Series Detail
|
|
||||||
- Cover, author, artist, status badge, genres, and synopsis
|
|
||||||
- Read progress bar with percentage
|
|
||||||
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
|
|
||||||
- Chapter list with scanlator, upload date, and in-progress page indicator
|
|
||||||
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
|
|
||||||
- Sort by newest or oldest first
|
|
||||||
- Jump-to-chapter input
|
|
||||||
- Bulk download menu: from current chapter, unread only, or all
|
|
||||||
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
|
|
||||||
- Collapsible source details panel with source ID, language, and source migration
|
|
||||||
|
|
||||||
### Search
|
|
||||||
- Cross-source search running up to 3 concurrent requests
|
|
||||||
- Language filter bar (preferred language default, per-language, or all)
|
|
||||||
- Results grouped by source with skeleton loading states
|
|
||||||
|
|
||||||
### Sources & Extensions
|
|
||||||
- Browse and search installed sources, grouped by extension with per-language expansion
|
|
||||||
- Extension manager: install, update, remove, and install from external APK URL
|
|
||||||
- Repo refresh with update count badge
|
|
||||||
|
|
||||||
### Downloads
|
|
||||||
- Download queue with live progress
|
|
||||||
|
|
||||||
### History
|
|
||||||
- Reading history grouped by day with relative timestamps
|
|
||||||
- Per-entry thumbnail, chapter name, and last-read page
|
|
||||||
- Full-text search across titles and chapter names
|
|
||||||
- One-click clear
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
|
||||||
|
|
||||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
**Nix (recommended)**
|
### Flatpak (Linux, recommended)
|
||||||
|
|
||||||
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:Youwes09/moku
|
flatpak install moku.flatpak
|
||||||
|
flatpak run dev.moku.app
|
||||||
|
```
|
||||||
|
|
||||||
|
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run github:Youwes09/Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
Add to your flake:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.moku.url = "github:Youwes09/moku";
|
inputs.moku.url = "github:Youwes09/Moku";
|
||||||
```
|
```
|
||||||
|
|
||||||
**From source**
|
### Windows
|
||||||
|
|
||||||
```bash
|
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||||
git clone https://github.com/Youwes09/moku
|
|
||||||
cd moku
|
### macOS
|
||||||
nix build
|
|
||||||
./result/bin/moku
|
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||||
```
|
|
||||||
|
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||||
|
> ```bash
|
||||||
|
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||||
|
|
||||||
|
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Youwes09/Moku
|
||||||
|
cd Moku
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Nix:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix develop
|
nix develop
|
||||||
pnpm install
|
pnpm install
|
||||||
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 |
|
||||||
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||||
| [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,22 +1,42 @@
|
|||||||
Todo:
|
Major Revisions:
|
||||||
1. Check all Keybind Toggles
|
- Moku + Crossplatform Support (MacOS Remaining)
|
||||||
2. Update ReadME with Comprehensive Feature List
|
- Contemplate Anime Support, Add Novel Support (Consumet API)
|
||||||
3. Explore Manga Upscaler
|
- Enable Cloudflare Bypass (Suwayomi Config)
|
||||||
4. Add Zoom-Slider for Zoom in Manga Reader
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
|
- Adjustment in Settings for Theme Editor:
|
||||||
|
- Allow User to Edit/Create Themes
|
||||||
|
- Allow For Command-Line IPC for Temporary (Apply Once) & Permanent Themes OR External Methodology for Matugen Colors
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
Bugs:
|
Important Commands:
|
||||||
2. Fix Auto-scroll between chapters & state when moving chapters (Implemented but not Fluidic)
|
cd ~/Projects/Manga/Moku
|
||||||
3. Patch Chapters to Grid View
|
pnpm build
|
||||||
5. Fix Keybind Toggles
|
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||||
|
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
||||||
Features:
|
|
||||||
1. Frecency based Manga Suggestions
|
|
||||||
2. Proper Explore Tab
|
|
||||||
|
|
||||||
|
|
||||||
Big Revisions:
|
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"
|
||||||
1. Anime & Novel Support
|
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
||||||
|
flatpak build-bundle repo moku.flatpak dev.moku.app
|
||||||
Test:
|
|
||||||
1. URL & Extension Additions
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# build-scripts/release.sh
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Usage:
|
|
||||||
# ./build-scripts/release.sh 0.2.0 — full release (AUR + Flatpak)
|
|
||||||
# ./build-scripts/release.sh 0.2.0 --aur — AUR bin package only
|
|
||||||
# ./build-scripts/release.sh 0.2.0 --flatpak — Flatpak sources + bundle only
|
|
||||||
#
|
|
||||||
# Requires: nix, podman (for AUR .SRCINFO generation in Arch container)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Colour helpers ─────────────────────────────────────────────────────────────
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
|
||||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
|
||||||
|
|
||||||
info() { echo -e "${CYAN} →${RESET} $*"; }
|
|
||||||
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW} ⚠${RESET} $*"; }
|
|
||||||
die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
|
||||||
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
|
||||||
|
|
||||||
# ── Args ───────────────────────────────────────────────────────────────────────
|
|
||||||
[[ $# -lt 1 ]] && die "Usage: $0 <version> [--aur|--flatpak]"
|
|
||||||
|
|
||||||
VERSION="$1"
|
|
||||||
MODE="${2:-all}"
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
||||||
AUR_DIR="${REPO_ROOT}/../moku-bin"
|
|
||||||
TARBALL="moku-${VERSION}-x86_64.tar.gz"
|
|
||||||
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
|
|
||||||
|
|
||||||
# ── Sanity checks ──────────────────────────────────────────────────────────────
|
|
||||||
section "Pre-flight"
|
|
||||||
command -v nix &>/dev/null || die "nix not found"
|
|
||||||
|
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
|
||||||
command -v podman &>/dev/null || die "podman not found — needed for Arch container (makepkg)"
|
|
||||||
[[ -d "$AUR_DIR" ]] || die "AUR dir not found at $AUR_DIR\nClone it first:\n git clone ssh://aur@aur.archlinux.org/moku-bin.git ../moku-bin"
|
|
||||||
[[ -f "${AUR_DIR}/PKGBUILD" ]] || die "PKGBUILD not found in $AUR_DIR"
|
|
||||||
fi
|
|
||||||
success "OK"
|
|
||||||
|
|
||||||
# ── Bump versions ──────────────────────────────────────────────────────────────
|
|
||||||
section "Bumping version → ${VERSION}"
|
|
||||||
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \
|
|
||||||
"${REPO_ROOT}/src-tauri/tauri.conf.json"
|
|
||||||
success "tauri.conf.json → ${VERSION}"
|
|
||||||
|
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"${VERSION}\"/" \
|
|
||||||
"${REPO_ROOT}/src-tauri/Cargo.toml"
|
|
||||||
success "Cargo.toml → ${VERSION}"
|
|
||||||
|
|
||||||
# ── Build frontend ─────────────────────────────────────────────────────────────
|
|
||||||
section "Building frontend"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
nix develop --command pnpm install --frozen-lockfile
|
|
||||||
nix develop --command pnpm build
|
|
||||||
success "Frontend built → dist/"
|
|
||||||
|
|
||||||
# ── Build Rust binary ──────────────────────────────────────────────────────────
|
|
||||||
section "Building Rust binary"
|
|
||||||
nix develop --command cargo build --release --manifest-path src-tauri/Cargo.toml
|
|
||||||
|
|
||||||
BINARY="${REPO_ROOT}/src-tauri/target/release/moku"
|
|
||||||
[[ -f "$BINARY" ]] || die "Binary not found: $BINARY"
|
|
||||||
success "Binary → $BINARY"
|
|
||||||
|
|
||||||
# ── Flatpak ────────────────────────────────────────────────────────────────────
|
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--flatpak" ]]; then
|
|
||||||
section "Regenerating cargo-sources.json"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
nix-shell \
|
|
||||||
-p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" \
|
|
||||||
--run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
|
||||||
success "cargo-sources.json updated"
|
|
||||||
|
|
||||||
section "Rebuilding frontend-dist.tar.gz"
|
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
|
||||||
success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}"
|
|
||||||
|
|
||||||
# Patch the sha256 in dev.moku.app.yml automatically via a temp script
|
|
||||||
PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py)
|
|
||||||
cat > "$PATCH_SCRIPT" << PYEOF
|
|
||||||
import re, sys
|
|
||||||
|
|
||||||
path = "${FLATPAK_MANIFEST}"
|
|
||||||
new_sha = "${FRONTEND_SHA}"
|
|
||||||
text = open(path).read()
|
|
||||||
|
|
||||||
pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+'
|
|
||||||
replacement = r'\g<1>' + new_sha
|
|
||||||
updated, n = re.subn(pattern, replacement, text)
|
|
||||||
if n == 0:
|
|
||||||
sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml")
|
|
||||||
open(path, 'w').write(updated)
|
|
||||||
PYEOF
|
|
||||||
nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'"
|
|
||||||
rm -f "$PATCH_SCRIPT"
|
|
||||||
success "dev.moku.app.yml sha256 updated"
|
|
||||||
|
|
||||||
section "Building Flatpak bundle"
|
|
||||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
|
||||||
|
|
||||||
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \
|
|
||||||
flatpak-builder \
|
|
||||||
--repo="${REPO_ROOT}/repo" \
|
|
||||||
--force-clean \
|
|
||||||
"${REPO_ROOT}/build-dir" \
|
|
||||||
"$FLATPAK_MANIFEST"
|
|
||||||
|
|
||||||
flatpak build-bundle \
|
|
||||||
"${REPO_ROOT}/repo" \
|
|
||||||
"${REPO_ROOT}/moku.flatpak" \
|
|
||||||
dev.moku.app
|
|
||||||
|
|
||||||
# Clean up intermediate build artefacts — keep only moku.flatpak
|
|
||||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
|
||||||
success "moku.flatpak created"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── AUR tarball + PKGBUILD ─────────────────────────────────────────────────────
|
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
|
||||||
section "Assembling release tarball"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
STAGE="release-${VERSION}"
|
|
||||||
rm -rf "$STAGE"
|
|
||||||
|
|
||||||
install -Dm755 "$BINARY" "${STAGE}/usr/bin/moku"
|
|
||||||
install -Dm644 packaging/dev.moku.app.desktop "${STAGE}/usr/share/applications/dev.moku.app.desktop"
|
|
||||||
install -Dm644 src-tauri/icons/32x32.png "${STAGE}/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
|
||||||
install -Dm644 src-tauri/icons/128x128.png "${STAGE}/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
|
||||||
install -Dm644 "src-tauri/icons/128x128@2x.png" "${STAGE}/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
|
||||||
install -Dm644 packaging/dev.moku.app.metainfo.xml "${STAGE}/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
|
||||||
|
|
||||||
tar -czf "$TARBALL" "$STAGE/"
|
|
||||||
AUR_SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
|
|
||||||
rm -rf "$STAGE"
|
|
||||||
success "Tarball: ${TARBALL} sha256: ${AUR_SHA}"
|
|
||||||
|
|
||||||
section "Patching PKGBUILD"
|
|
||||||
PKGBUILD="${AUR_DIR}/PKGBUILD"
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
|
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
|
||||||
sed -i "s/sha256sums=('[^']*')/sha256sums=('${AUR_SHA}')/" "$PKGBUILD"
|
|
||||||
success "PKGBUILD patched"
|
|
||||||
|
|
||||||
# Tarball is only needed for the GitHub upload — remind user then it can go
|
|
||||||
info "Tarball kept at ${REPO_ROOT}/${TARBALL} — upload it to GitHub, then it can be deleted"
|
|
||||||
|
|
||||||
section "Generating .SRCINFO (Arch container)"
|
|
||||||
# Mount only the AUR dir into a throwaway Arch container and run makepkg
|
|
||||||
podman run --rm \
|
|
||||||
--volume "${AUR_DIR}:/aur:z" \
|
|
||||||
--workdir /aur \
|
|
||||||
archlinux:latest \
|
|
||||||
bash -c "
|
|
||||||
pacman -Sy --noconfirm pacman >/dev/null 2>&1
|
|
||||||
source PKGBUILD
|
|
||||||
makepkg --printsrcinfo > .SRCINFO
|
|
||||||
"
|
|
||||||
success ".SRCINFO generated"
|
|
||||||
|
|
||||||
section "Next steps"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}1. Upload tarball to GitHub:${RESET}"
|
|
||||||
echo -e " ${CYAN}gh release create v${VERSION} '${REPO_ROOT}/${TARBALL}' --title 'v${VERSION}' --generate-notes${RESET}"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}2. Push AUR:${RESET}"
|
|
||||||
echo -e " ${CYAN}cd ${AUR_DIR}${RESET}"
|
|
||||||
echo -e " ${CYAN}git add PKGBUILD .SRCINFO${RESET}"
|
|
||||||
echo -e " ${CYAN}git commit -m 'Update to ${VERSION}'${RESET}"
|
|
||||||
echo -e " ${CYAN}git push origin master${RESET}"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}3. Clean up:${RESET}"
|
|
||||||
echo -e " ${CYAN}rm -f ${REPO_ROOT}/${TARBALL}${RESET}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
success "v${VERSION} ready"
|
|
||||||
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a
|
sha256: 3ac5d822ac1840473333510b5e45220298702e6d1435e2cdd4b5c2f7195d764f
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
|
||||||
|
<defs>
|
||||||
|
|
||||||
|
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#52b888"/>
|
||||||
|
<stop offset="100%" stop-color="#1e5840"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<clipPath id="roundedBounds">
|
||||||
|
<rect width="1280" height="320" rx="18" ry="18"/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g clip-path="url(#roundedBounds)">
|
||||||
|
|
||||||
|
<rect width="1280" height="320" fill="#070e09"/>
|
||||||
|
|
||||||
|
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
|
||||||
|
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
|
||||||
|
fill="url(#leafHero)" opacity="0.97">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Stack text pinned to bottom -->
|
||||||
|
<text
|
||||||
|
x="640" y="300"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
|
||||||
|
font-size="14"
|
||||||
|
letter-spacing="5"
|
||||||
|
fill="#a8c4a8"
|
||||||
|
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 7.1 MiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 914 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 609 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 151 KiB |
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771438068,
|
"lastModified": 1773857772,
|
||||||
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=",
|
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597",
|
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -15,32 +15,16 @@
|
|||||||
"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": 1769996383,
|
"lastModified": 1772408722,
|
||||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -49,53 +33,13 @@
|
|||||||
"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": 1771369470,
|
"lastModified": 1773821835,
|
||||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -107,11 +51,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1769909678,
|
"lastModified": 1772328832,
|
||||||
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -124,7 +68,6 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -136,11 +79,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771556776,
|
"lastModified": 1773975983,
|
||||||
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
|
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
|
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -148,21 +91,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -9,36 +9,27 @@
|
|||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
nix-appimage = {
|
|
||||||
url = "github:ralismark/nix-appimage";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{ flake-parts, crane, rust-overlay, nix-appimage, ... }:
|
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [
|
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||||
"x86_64-linux"
|
|
||||||
"aarch64-linux"
|
|
||||||
];
|
|
||||||
|
|
||||||
perSystem =
|
perSystem = { system, lib, ... }:
|
||||||
{ system, pkgs, lib, ... }:
|
|
||||||
let
|
let
|
||||||
pkgs' = import inputs.nixpkgs {
|
version = "0.5.0";
|
||||||
|
|
||||||
|
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 = [
|
extensions = [ "rust-src" "rust-analyzer" ];
|
||||||
"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
|
||||||
@@ -65,31 +56,22 @@
|
|||||||
|| base == "package.json"
|
|| base == "package.json"
|
||||||
|| base == "pnpm-lock.yaml"
|
|| base == "pnpm-lock.yaml"
|
||||||
|| base == "tsconfig.json"
|
|| base == "tsconfig.json"
|
||||||
|| base == "tsconfig.node.json"
|
|| base == "vite.config.ts";
|
||||||
|| 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";
|
||||||
version = "0.1.0";
|
inherit version;
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
||||||
nodejs_22
|
|
||||||
pnpm
|
|
||||||
pnpmConfigHook
|
|
||||||
];
|
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
pname = "moku-frontend";
|
pname = "moku-frontend";
|
||||||
version = "0.1.0";
|
inherit version;
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 1;
|
||||||
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
hash = "sha256-4QUSgWgMu7FGn44+TGmACheokPhaBdHvA/055SqUs0Q=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
buildPhase = "pnpm build";
|
||||||
@@ -111,14 +93,10 @@
|
|||||||
cargoLock = ./src-tauri/Cargo.lock;
|
cargoLock = ./src-tauri/Cargo.lock;
|
||||||
strictDeps = true;
|
strictDeps = true;
|
||||||
buildInputs = runtimeLibs;
|
buildInputs = runtimeLibs;
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
||||||
pkg-config
|
|
||||||
wrapGAppsHook3
|
|
||||||
];
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r ${frontend} ../dist
|
cp -r ${frontend} ../dist
|
||||||
'';
|
'';
|
||||||
WEBKIT_DISABLE_COMPOSITING_MODE = "1";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
@@ -127,22 +105,177 @@
|
|||||||
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
|
||||||
pkgs.gtk3
|
pkgs.gtk3
|
||||||
]}" \
|
]}" \
|
||||||
--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 WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bumpScript = pkgs.writeShellApplication {
|
||||||
|
name = "moku-bump";
|
||||||
|
runtimeInputs = with pkgs; [ gnused coreutils git ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/tauri.conf.json"
|
||||||
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/Cargo.toml"
|
||||||
|
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||||
|
"$REPO/flake.nix"
|
||||||
|
echo "Bumped to $VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
flatpakScript = pkgs.writeShellApplication {
|
||||||
|
name = "moku-flatpak";
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
gnused coreutils git
|
||||||
|
nodejs_22 pnpm
|
||||||
|
appstream flatpak-builder flatpak
|
||||||
|
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
MANIFEST="$REPO/dev.moku.app.yml"
|
||||||
|
|
||||||
|
echo "── Bumping versions ──"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/tauri.conf.json"
|
||||||
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/Cargo.toml"
|
||||||
|
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||||
|
"$REPO/flake.nix"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Building frontend ──"
|
||||||
|
cd "$REPO"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Repacking frontend-dist.tar.gz ──"
|
||||||
|
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO/dist" .
|
||||||
|
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||||
|
echo "sha256: $FRONTEND_SHA"
|
||||||
|
|
||||||
|
echo "── Patching manifest sha256 ──"
|
||||||
|
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
||||||
|
import re, sys
|
||||||
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(path).read()
|
||||||
|
updated, n = re.subn(
|
||||||
|
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||||
|
r'\g<1>' + sha, text)
|
||||||
|
if n == 0:
|
||||||
|
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||||
|
open(path, 'w').write(updated)
|
||||||
|
PYEOF
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Regenerating cargo-sources.json ──"
|
||||||
|
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||||
|
"$REPO/src-tauri/Cargo.lock" \
|
||||||
|
-o "$REPO/packaging/cargo-sources.json"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Building flatpak ──"
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
flatpak-builder \
|
||||||
|
--repo="$REPO/repo" \
|
||||||
|
--force-clean \
|
||||||
|
"$REPO/build-dir" \
|
||||||
|
"$MANIFEST"
|
||||||
|
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" dev.moku.app
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
echo "moku.flatpak created"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done — v$VERSION"
|
||||||
|
echo " -> $REPO/moku.flatpak"
|
||||||
|
echo ""
|
||||||
|
echo "After pushing the tag, run:"
|
||||||
|
echo " nix run .#pkgbuild-bump -- $VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
pkgbuildBumpScript = pkgs.writeShellApplication {
|
||||||
|
name = "moku-pkgbuild-bump";
|
||||||
|
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
PKGBUILD="$REPO/PKGBUILD"
|
||||||
|
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
||||||
|
|
||||||
|
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||||
|
echo "Fetching tarball sha256..."
|
||||||
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
||||||
|
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
||||||
|
|
||||||
|
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||||
|
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
|
||||||
|
|
||||||
|
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
apps = {
|
||||||
|
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
|
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
|
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
||||||
|
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
||||||
|
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
||||||
|
};
|
||||||
|
|
||||||
packages = {
|
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 {
|
||||||
@@ -157,27 +290,16 @@
|
|||||||
xdg-utils
|
xdg-utils
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
|
||||||
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}"
|
||||||
|
|
||||||
if [ ! -e /usr/bin/xdg-open ]; then
|
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||||
sudo ln -sf ${pkgs.xdg-utils}/bin/xdg-open /usr/bin/xdg-open
|
echo ""
|
||||||
fi
|
echo "Release:"
|
||||||
|
echo " nix run .#bump -- <ver> bump versions only"
|
||||||
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage"
|
echo " nix run .#flatpak -- <ver> full flatpak build"
|
||||||
LINUXDEPLOY_REAL="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage.real"
|
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
||||||
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="root"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,41 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "moku",
|
"name": "moku",
|
||||||
"private": true,
|
"version": "0.5.0",
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "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-shell": "~2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"phosphor-svelte": "^3.1.0",
|
||||||
"react": "^18.3.1",
|
"svelte-spa-router": "^4.0.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",
|
||||||
"@types/react": "^18.3.3",
|
"svelte": "^5.0.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"svelte-check": "^3.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"typescript": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"vite": "^5.0.0"
|
||||||
"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.1.0" date="2025-01-01">
|
<release version="0.4.0" date="2025-03-22">
|
||||||
<description>
|
<description>
|
||||||
<p>Initial release.</p>
|
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||||
</description>
|
</description>
|
||||||
</release>
|
</release>
|
||||||
</releases>
|
</releases>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "moku_lib"
|
name = "moku_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
@@ -17,11 +17,15 @@ 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"
|
||||||
nix = { version = "0.29", features = ["fs"] }
|
sysinfo = "0.32"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
tauri-plugin-os = "2.3.2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Moku — Suwayomi launcher sidecar for macOS.
|
||||||
|
# Tauri calls this script directly as a sidecar (Contents/MacOS/suwayomi-server-{arch}).
|
||||||
|
# The Suwayomi bundle is placed by Tauri into Contents/Resources/suwayomi-bundle/.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Resolve the real directory of this script, following symlinks.
|
||||||
|
SELF="$0"
|
||||||
|
while [ -L "$SELF" ]; do
|
||||||
|
SELF="$(readlink "$SELF")"
|
||||||
|
done
|
||||||
|
DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||||
|
|
||||||
|
# ── Locate the bundle ─────────────────────────────────────────────────────────
|
||||||
|
# Inside .app: sidecar = Contents/MacOS/suwayomi-server-{arch}
|
||||||
|
# bundle = Contents/Resources/suwayomi-bundle/
|
||||||
|
# Dev / flat layout: bundle sits next to the sidecar, or one level up.
|
||||||
|
find_bundle() {
|
||||||
|
local base="$1"
|
||||||
|
for candidate in \
|
||||||
|
"${base}/../Resources/suwayomi-bundle" \
|
||||||
|
"${base}/suwayomi-bundle" \
|
||||||
|
"${base}/../suwayomi-bundle"
|
||||||
|
do
|
||||||
|
# The jar lives at <bundle>/bin/Suwayomi-Server.jar
|
||||||
|
if [ -f "${candidate}/bin/Suwayomi-Server.jar" ]; then
|
||||||
|
# Canonicalise (no readlink -f on older macOS sh, use cd trick)
|
||||||
|
echo "$(cd "$candidate" && pwd)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
BUNDLE=$(find_bundle "$DIR") || {
|
||||||
|
echo "[sidecar] ERROR: cannot locate suwayomi-bundle relative to $DIR" >&2
|
||||||
|
echo "[sidecar] Tried:" >&2
|
||||||
|
echo " $DIR/../Resources/suwayomi-bundle" >&2
|
||||||
|
echo " $DIR/suwayomi-bundle" >&2
|
||||||
|
echo " $DIR/../suwayomi-bundle" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
JAVA="${BUNDLE}/jre/bin/java"
|
||||||
|
JAR="${BUNDLE}/bin/Suwayomi-Server.jar"
|
||||||
|
|
||||||
|
echo "[sidecar] BUNDLE=$BUNDLE" >&2
|
||||||
|
echo "[sidecar] JAVA=$JAVA" >&2
|
||||||
|
echo "[sidecar] JAR=$JAR" >&2
|
||||||
|
|
||||||
|
if [ ! -x "$JAVA" ]; then
|
||||||
|
echo "[sidecar] ERROR: java not executable at $JAVA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$JAR" ]; then
|
||||||
|
echo "[sidecar] ERROR: jar not found at $JAR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# "$@" will contain the -Dsuwayomi.tachidesk.config.server.rootDir=... flag
|
||||||
|
# prepended by spawn_server in lib.rs, followed by -jar <path>.
|
||||||
|
# We call java directly so all JVM flags reach it properly.
|
||||||
|
exec "$JAVA" \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
"$@" \
|
||||||
|
-jar "$JAR"
|
||||||
@@ -1,19 +1,37 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Allow launching tachidesk-server",
|
"description": "Default permissions for Moku",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
{
|
"shell:allow-kill",
|
||||||
"identifier": "shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
"allow": [
|
"shell:allow-execute",
|
||||||
{
|
"core:window:allow-minimize",
|
||||||
"name": "tachidesk-server",
|
"core:window:allow-unminimize",
|
||||||
"cmd": "tachidesk-server"
|
"core:window:allow-maximize",
|
||||||
}
|
"core:window:allow-unmaximize",
|
||||||
]
|
"core:window:allow-toggle-maximize",
|
||||||
}
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-set-fullscreen",
|
||||||
|
"core:window:allow-is-fullscreen",
|
||||||
|
"core:window:allow-is-maximized",
|
||||||
|
"core:window:allow-is-minimized",
|
||||||
|
"core:window:allow-inner-size",
|
||||||
|
"core:window:allow-outer-size",
|
||||||
|
"core:window:allow-inner-position",
|
||||||
|
"core:window:allow-outer-position",
|
||||||
|
"core:window:allow-scale-factor",
|
||||||
|
"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.8 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 803 B After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,8 +1,11 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use nix::sys::statvfs::statvfs;
|
use std::io::Write;
|
||||||
|
use sysinfo::Disks;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::Manager;
|
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;
|
||||||
|
|
||||||
@@ -16,17 +19,51 @@ 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(|_| {
|
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
||||||
dirs::home_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("/"))
|
|
||||||
.join(".local/share")
|
|
||||||
});
|
|
||||||
base.join("Tachidesk/downloads")
|
base.join("Tachidesk/downloads")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,51 +83,516 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
let stat_path = if path.exists() { path.clone() } else {
|
let stat_path = if path.exists() {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||||
};
|
};
|
||||||
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// f_frsize is the fundamental block size used for block counts.
|
let disks = Disks::new_with_refreshed_list();
|
||||||
// f_bsize (block_size()) is just the preferred I/O size and must not be
|
let disk = disks
|
||||||
// used with blocks()/blocks_free() — that gives wildly wrong numbers.
|
.iter()
|
||||||
let frsize = vfs.fragment_size() as u64;
|
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||||
let total_bytes = vfs.blocks() * frsize;
|
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||||
let free_bytes = vfs.blocks_available() * frsize;
|
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||||
|
|
||||||
Ok(StorageInfo {
|
Ok(StorageInfo {
|
||||||
manga_bytes,
|
manga_bytes,
|
||||||
total_bytes,
|
total_bytes: disk.total_space(),
|
||||||
free_bytes,
|
free_bytes: disk.available_space(),
|
||||||
path: path.to_string_lossy().into_owned(),
|
path: path.to_string_lossy().into_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_platform_ui_scale() -> f64 {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return 1.0;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return 1.0;
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
return 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
if let Some(child) = state.0.lock().unwrap().take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status();
|
||||||
|
|
||||||
|
// 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"))]
|
||||||
|
let _ = std::process::Command::new("pkill")
|
||||||
|
.args(["-f", "tachidesk"])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = false
|
||||||
|
server.initialOpenInBrowserEnabled = false
|
||||||
|
server.systemTrayEnabled = false
|
||||||
|
server.webUIInterface = "browser"
|
||||||
|
server.webUIFlavor = "WebUI"
|
||||||
|
server.webUIChannel = "stable"
|
||||||
|
server.electronPath = ""
|
||||||
|
server.debugLogsEnabled = false
|
||||||
|
server.downloadAsCbz = true
|
||||||
|
server.autoDownloadNewChapters = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
server.extensionRepos = []
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn seed_server_conf(data_dir: &PathBuf) {
|
||||||
|
let conf_path = data_dir.join("server.conf");
|
||||||
|
|
||||||
|
if !conf_path.exists() {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||||
|
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
||||||
|
eprintln!("Could not write server.conf: {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
||||||
|
|
||||||
|
let patched = patch_conf_key(
|
||||||
|
patch_conf_key(
|
||||||
|
patch_conf_key(contents, "server.webUIEnabled", "false"),
|
||||||
|
"server.initialOpenInBrowserEnabled", "false",
|
||||||
|
),
|
||||||
|
"server.systemTrayEnabled", "false",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::write(&conf_path, patched);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
||||||
|
let replacement = format!("{key} = {value}");
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
|
||||||
|
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
||||||
|
let mut out = lines
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
out.push('\n');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = text;
|
||||||
|
if !out.ends_with('\n') { out.push('\n'); }
|
||||||
|
out.push_str(&replacement);
|
||||||
|
out.push('\n');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suwayomi_data_dir() -> PathBuf {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
||||||
|
.join("moku\\tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||||
|
.join("dev.moku.app/tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
||||||
|
base.join("moku/tachidesk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerInvocation {
|
||||||
|
bin: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
working_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let java = bundle_dir.join("jre").join("bin").join("java.exe");
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||||
|
|
||||||
|
do_log(log, &format!("[find_java] checking path: {:?}", java));
|
||||||
|
do_log(log, &format!("[find_java] exists: {}", java.exists()));
|
||||||
|
|
||||||
|
if java.exists() { Some(java) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
if let Some(f) = log {
|
||||||
|
let _ = writeln!(f, "{}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_server_binary(
|
||||||
|
binary: &str,
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
log: &mut Option<std::fs::File>,
|
||||||
|
) -> Result<ServerInvocation, SpawnError> {
|
||||||
|
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
||||||
|
|
||||||
|
if !binary.trim().is_empty() {
|
||||||
|
do_log(log, "[resolve] using user-supplied binary path");
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: binary.to_string(),
|
||||||
|
args: vec![],
|
||||||
|
working_dir: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let resource_dir = match app.path().resource_dir() {
|
||||||
|
Ok(p) => {
|
||||||
|
let stripped = strip_unc(p);
|
||||||
|
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
|
||||||
|
stripped
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!("resource_dir error: {e}");
|
||||||
|
do_log(log, &format!("[resolve] ERROR: {}", msg));
|
||||||
|
return Err(SpawnError::SpawnFailed(msg));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||||
|
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||||
|
|
||||||
|
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir));
|
||||||
|
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists()));
|
||||||
|
do_log(log, &format!("[resolve] jar = {:?}", jar));
|
||||||
|
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
|
||||||
|
|
||||||
|
match find_java_in_bundle(&bundle_dir, log) {
|
||||||
|
Some(java) => {
|
||||||
|
do_log(log, &format!("[resolve] java found: {:?}", java));
|
||||||
|
if jar.exists() {
|
||||||
|
do_log(log, "[resolve] both java and jar found — using bundled JRE");
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec![
|
||||||
|
"-jar".to_string(),
|
||||||
|
jar.to_string_lossy().into_owned(),
|
||||||
|
],
|
||||||
|
working_dir: Some(bundle_dir),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
// 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 {
|
||||||
|
bin: name.to_string(),
|
||||||
|
args: vec![],
|
||||||
|
working_dir: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do_log(log, "[resolve] FAILED — no binary found anywhere");
|
||||||
|
Err(SpawnError::NotConfigured(
|
||||||
|
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
||||||
|
{
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
if state.0.lock().unwrap().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
|
||||||
|
let log_path = data_dir.join("moku-spawn.log");
|
||||||
|
let _ = std::fs::create_dir_all(&data_dir);
|
||||||
|
let mut log = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
do_log(&mut log, "");
|
||||||
|
do_log(&mut log, "========================================");
|
||||||
|
do_log(&mut log, &format!("[spawn_server] called at {:?}", std::time::SystemTime::now()));
|
||||||
|
do_log(&mut log, &format!("[spawn_server] binary arg = {:?}", binary));
|
||||||
|
do_log(&mut log, &format!("[spawn_server] data_dir = {:?}", data_dir));
|
||||||
|
do_log(&mut log, &format!("[spawn_server] log file = {:?}", log_path));
|
||||||
|
do_log(&mut log, &format!("[spawn_server] APPDATA = {:?}", std::env::var("APPDATA")));
|
||||||
|
do_log(&mut log, &format!("[spawn_server] LOCALAPPDATA = {:?}", std::env::var("LOCALAPPDATA")));
|
||||||
|
do_log(&mut log, &format!("[spawn_server] current_dir = {:?}", std::env::current_dir()));
|
||||||
|
|
||||||
|
seed_server_conf(&data_dir);
|
||||||
|
do_log(&mut log, "[spawn_server] server.conf seeded");
|
||||||
|
|
||||||
|
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e));
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let bin_display = invocation.bin.clone();
|
||||||
|
let rootdir_flag = format!(
|
||||||
|
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||||
|
data_dir.to_string_lossy()
|
||||||
|
);
|
||||||
|
|
||||||
|
invocation.args.insert(0, rootdir_flag);
|
||||||
|
|
||||||
|
let working_dir = invocation.working_dir
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
|
|
||||||
|
do_log(&mut log, &format!("[spawn_server] bin = {:?}", bin_display));
|
||||||
|
do_log(&mut log, &format!("[spawn_server] args = {:?}", invocation.args));
|
||||||
|
do_log(&mut log, &format!("[spawn_server] working_dir = {:?}", working_dir));
|
||||||
|
|
||||||
|
let cmd = app.shell()
|
||||||
|
.command(&invocation.bin)
|
||||||
|
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||||
|
.args(&invocation.args)
|
||||||
|
.current_dir(&working_dir);
|
||||||
|
|
||||||
|
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
|
||||||
|
|
||||||
|
match cmd.spawn() {
|
||||||
|
Ok((_rx, child)) => {
|
||||||
|
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
|
||||||
|
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e));
|
||||||
|
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
|
||||||
|
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
kill_tachidesk(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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![get_storage_info])
|
.invoke_handler(tauri::generate_handler![
|
||||||
.setup(|app| {
|
get_storage_info,
|
||||||
let shell = app.shell();
|
spawn_server,
|
||||||
let app_handle = app.handle().clone();
|
kill_server,
|
||||||
|
get_platform_ui_scale,
|
||||||
let status = shell.command("tachidesk-server").spawn();
|
list_releases,
|
||||||
|
download_and_install_update,
|
||||||
match status {
|
restart_app,
|
||||||
Ok((_rx, child)) => {
|
])
|
||||||
println!("Tachidesk server process spawned successfully.");
|
.setup(|_app| Ok(()))
|
||||||
let state = app_handle.state::<ServerState>();
|
.on_window_event(|window, event| {
|
||||||
let mut guard = state.0.lock().unwrap();
|
if let WindowEvent::Destroyed = event {
|
||||||
*guard = Some(child);
|
kill_tachidesk(window.app_handle());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to spawn Tachidesk server: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.2.0",
|
"version": "0.5.0",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "dev.moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"decorations": false
|
"decorations": false,
|
||||||
|
"center": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
@@ -26,18 +27,32 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["appimage"],
|
"targets": [
|
||||||
|
"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,5 +9,8 @@
|
|||||||
"devtools": true
|
"devtools": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"externalBin": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"decorations": true,
|
||||||
|
"titleBarStyle": "Overlay",
|
||||||
|
"hiddenTitle": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["dmg"],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/suwayomi-server"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"binaries/suwayomi-bundle": "suwayomi-bundle"
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "11.0",
|
||||||
|
"exceptionDomain": "localhost",
|
||||||
|
"frameworks": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<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>
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import "./styles/global.css";
|
|
||||||
import { useStore } from "./store";
|
|
||||||
import Layout from "./components/layout/Layout";
|
|
||||||
import Reader from "./components/pages/Reader";
|
|
||||||
import Settings from "./components/settings/Settings";
|
|
||||||
import TitleBar from "./components/layout/TitleBar";
|
|
||||||
import s from "./App.module.css";
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const activeChapter = useStore((s) => s.activeChapter);
|
|
||||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
|
||||||
const settings = useStore((s) => s.settings);
|
|
||||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.style.zoom = `${settings.uiScale}%`;
|
|
||||||
}, [settings.uiScale]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prevent = (e: MouseEvent) => e.preventDefault();
|
|
||||||
document.addEventListener("contextmenu", prevent);
|
|
||||||
return () => document.removeEventListener("contextmenu", prevent);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!settings.autoStartServer) return;
|
|
||||||
invoke("spawn_server", { binary: settings.serverBinary }).catch((err) =>
|
|
||||||
console.warn("Could not start server:", err)
|
|
||||||
);
|
|
||||||
return () => { invoke("kill_server").catch(() => {}); };
|
|
||||||
}, [settings.autoStartServer, settings.serverBinary]);
|
|
||||||
|
|
||||||
// Global Tauri download-progress listener — no polling, always current
|
|
||||||
useEffect(() => {
|
|
||||||
type DlPayload = { chapterId: number; mangaId: number; progress: number }[];
|
|
||||||
const unsub = listen<DlPayload>("download-progress", (e) => {
|
|
||||||
setActiveDownloads(e.payload);
|
|
||||||
});
|
|
||||||
return () => { unsub.then((fn) => fn()); };
|
|
||||||
}, [setActiveDownloads]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
{!activeChapter && <TitleBar />}
|
|
||||||
<div className={s.content}>
|
|
||||||
{activeChapter ? <Reader /> : <Layout />}
|
|
||||||
</div>
|
|
||||||
{settingsOpen && <Settings />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<g transform="translate(256,265) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,11 +1,6 @@
|
|||||||
<?xml version="1.0" standalone="no"?>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
<rect width="512" height="512" rx="112" ry="112" fill="#091209"/>
|
||||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 500.000000 500.000000"
|
|
||||||
preserveAspectRatio="xMidYMid meet">
|
|
||||||
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)"
|
|
||||||
fill="#2d7a5f" stroke="none">
|
|
||||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
<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
|
-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
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,83 +0,0 @@
|
|||||||
.menu {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 200;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--sp-1);
|
|
||||||
min-width: 190px;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(0,0,0,0.08),
|
|
||||||
0 4px 12px rgba(0,0,0,0.35),
|
|
||||||
0 16px 40px rgba(0,0,0,0.25);
|
|
||||||
animation: scaleIn 0.1s ease both;
|
|
||||||
transform-origin: top left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px var(--sp-2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:hover:not(:disabled),
|
|
||||||
.itemFocused:not(:disabled) {
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon area — fixed-width column so labels align */
|
|
||||||
.itemIconWrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-fast);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:hover .itemIconWrap,
|
|
||||||
.itemFocused .itemIconWrap {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.itemLabel {
|
|
||||||
flex: 1;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Danger variant */
|
|
||||||
.itemDanger { color: var(--color-error); }
|
|
||||||
.itemDanger:hover:not(:disabled),
|
|
||||||
.itemDanger.itemFocused:not(:disabled) {
|
|
||||||
background: var(--color-error-bg);
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
.itemIconDanger { color: var(--color-error) !important; opacity: 0.7; }
|
|
||||||
|
|
||||||
/* Disabled */
|
|
||||||
.itemDisabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: default;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border-dim);
|
|
||||||
margin: 3px var(--sp-1);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import s from "./ContextMenu.module.css";
|
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
|
||||||
label: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
onClick: () => void;
|
|
||||||
danger?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
separator?: never;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextMenuSeparator {
|
|
||||||
separator: true;
|
|
||||||
label?: never;
|
|
||||||
icon?: never;
|
|
||||||
onClick?: never;
|
|
||||||
danger?: never;
|
|
||||||
disabled?: never;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
items: ContextMenuEntry[];
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [focused, setFocused] = useState<number>(-1);
|
|
||||||
|
|
||||||
// Build list of actionable (non-separator, non-disabled) indices for keyboard nav
|
|
||||||
const actionable = items
|
|
||||||
.map((_, i) => i)
|
|
||||||
.filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onDown(e: MouseEvent) {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
|
||||||
}
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setFocused((prev) => {
|
|
||||||
const cur = actionable.indexOf(prev);
|
|
||||||
return actionable[(cur + 1) % actionable.length] ?? actionable[0];
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setFocused((prev) => {
|
|
||||||
const cur = actionable.indexOf(prev);
|
|
||||||
return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter" && focused >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
const item = items[focused] as ContextMenuItem;
|
|
||||||
if (item && !item.disabled) { item.onClick(); onClose(); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", onDown, true);
|
|
||||||
document.addEventListener("keydown", onKey, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", onDown, true);
|
|
||||||
document.removeEventListener("keydown", onKey, true);
|
|
||||||
};
|
|
||||||
}, [onClose, focused, actionable, items]);
|
|
||||||
|
|
||||||
// Focus first item on open
|
|
||||||
useEffect(() => {
|
|
||||||
if (actionable.length) setFocused(actionable[0]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getPosition = useCallback(() => {
|
|
||||||
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
|
||||||
const scaledX = x / zoom;
|
|
||||||
const scaledY = y / zoom;
|
|
||||||
const menuW = 200;
|
|
||||||
const menuH = items.length * 34;
|
|
||||||
const vw = window.innerWidth / zoom;
|
|
||||||
const vh = window.innerHeight / zoom;
|
|
||||||
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
|
||||||
const top = scaledY + menuH > vh ? scaledY - menuH : scaledY;
|
|
||||||
return { left: Math.max(4, left), top: Math.max(4, top) };
|
|
||||||
}, [x, y, items.length]);
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
ref={menuRef}
|
|
||||||
className={s.menu}
|
|
||||||
style={getPosition()}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{items.map((item, i) => {
|
|
||||||
if ("separator" in item && item.separator) {
|
|
||||||
return <div key={i} className={s.separator} />;
|
|
||||||
}
|
|
||||||
const mi = item as ContextMenuItem;
|
|
||||||
const isFocused = focused === i;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className={[
|
|
||||||
s.item,
|
|
||||||
mi.danger ? s.itemDanger : "",
|
|
||||||
mi.disabled ? s.itemDisabled : "",
|
|
||||||
isFocused ? s.itemFocused : "",
|
|
||||||
].filter(Boolean).join(" ")}
|
|
||||||
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
|
||||||
onMouseEnter={() => !mi.disabled && setFocused(i)}
|
|
||||||
onMouseLeave={() => setFocused(-1)}
|
|
||||||
disabled={mi.disabled}
|
|
||||||
>
|
|
||||||
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
|
|
||||||
{mi.icon ?? null}
|
|
||||||
</span>
|
|
||||||
<span className={s.itemLabel}>{mi.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
.root {
|
|
||||||
padding: var(--sp-6);
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: var(--sp-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerActions { display: flex; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.iconBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconBtn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.statusBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-3);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-bottom: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusDotActive {
|
|
||||||
background: var(--accent);
|
|
||||||
animation: pulse 1.6s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusText {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex: 1;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusCount {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-3);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowActive { border-color: var(--accent-dim); }
|
|
||||||
|
|
||||||
/* Thumbnail */
|
|
||||||
.thumb {
|
|
||||||
width: 36px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbImg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Info block */
|
|
||||||
.info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mangaTitle {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapterName {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagesLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressWrap {
|
|
||||||
height: 2px;
|
|
||||||
background: var(--border-base);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressBar {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right side */
|
|
||||||
.rowRight {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stateLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 160px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import {
|
|
||||||
GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER,
|
|
||||||
CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD,
|
|
||||||
} from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { DownloadStatus } from "../../lib/types";
|
|
||||||
import s from "./DownloadQueue.module.css";
|
|
||||||
|
|
||||||
export default function DownloadQueue() {
|
|
||||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then((d) => {
|
|
||||||
setStatus(d.downloadStatus);
|
|
||||||
setActiveDownloads(
|
|
||||||
d.downloadStatus.queue.map((item) => ({
|
|
||||||
chapterId: item.chapter.id,
|
|
||||||
mangaId: item.chapter.mangaId,
|
|
||||||
progress: item.progress,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
poll();
|
|
||||||
const id = setInterval(poll, 1500);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function start() { await gql(START_DOWNLOADER).catch(console.error); poll(); }
|
|
||||||
async function stop() { await gql(STOP_DOWNLOADER).catch(console.error); poll(); }
|
|
||||||
async function clear() { await gql(CLEAR_DOWNLOADER).catch(console.error); poll(); }
|
|
||||||
async function dequeue(chapterId: number) {
|
|
||||||
await gql(DEQUEUE_DOWNLOAD, { chapterId }).catch(console.error);
|
|
||||||
poll();
|
|
||||||
}
|
|
||||||
|
|
||||||
const queue = status?.queue ?? [];
|
|
||||||
const isRunning = status?.state === "STARTED";
|
|
||||||
|
|
||||||
function pagesDownloaded(progress: number, pageCount: number): number {
|
|
||||||
return Math.round(progress * pageCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<h1 className={s.heading}>Downloads</h1>
|
|
||||||
<div className={s.headerActions}>
|
|
||||||
{isRunning ? (
|
|
||||||
<button className={s.iconBtn} onClick={stop} title="Pause">
|
|
||||||
<Pause size={14} weight="fill" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className={s.iconBtn} onClick={start} disabled={queue.length === 0} title="Resume">
|
|
||||||
<Play size={14} weight="fill" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className={s.iconBtn} onClick={clear} disabled={queue.length === 0} title="Clear queue">
|
|
||||||
<Trash size={14} weight="regular" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.statusBar}>
|
|
||||||
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
|
||||||
<span className={s.statusText}>{isRunning ? "Downloading" : "Paused"}</span>
|
|
||||||
<span className={s.statusCount}>{queue.length} queued</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
) : queue.length === 0 ? (
|
|
||||||
<div className={s.empty}>Queue is empty.</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.list}>
|
|
||||||
{queue.map((item, i) => {
|
|
||||||
const isActive = i === 0 && isRunning;
|
|
||||||
const pages = item.chapter.pageCount ?? 0;
|
|
||||||
const done = pagesDownloaded(item.progress, pages);
|
|
||||||
const manga = item.chapter.manga;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.chapter.id}
|
|
||||||
className={[s.row, isActive ? s.rowActive : ""].join(" ").trim()}
|
|
||||||
>
|
|
||||||
{manga?.thumbnailUrl && (
|
|
||||||
<div className={s.thumb}>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(manga.thumbnailUrl)}
|
|
||||||
alt={manga.title}
|
|
||||||
className={s.thumbImg}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={s.info}>
|
|
||||||
{manga?.title && (
|
|
||||||
<span className={s.mangaTitle}>{manga.title}</span>
|
|
||||||
)}
|
|
||||||
<span className={s.chapterName}>{item.chapter.name}</span>
|
|
||||||
|
|
||||||
{pages > 0 && (
|
|
||||||
<span className={s.pagesLabel}>
|
|
||||||
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isActive && (
|
|
||||||
<div className={s.progressWrap}>
|
|
||||||
<div
|
|
||||||
className={s.progressBar}
|
|
||||||
style={{ width: `${Math.round(item.progress * 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.rowRight}>
|
|
||||||
<span className={s.stateLabel}>{item.state}</span>
|
|
||||||
{!isActive && (
|
|
||||||
<button
|
|
||||||
className={s.removeBtn}
|
|
||||||
onClick={() => dequeue(item.chapter.id)}
|
|
||||||
title="Remove from queue"
|
|
||||||
>
|
|
||||||
<X size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex; flex-direction: column; height: 100%;
|
|
||||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.headerActions { display: flex; gap: var(--sp-1); }
|
|
||||||
.iconBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
|
||||||
color: var(--text-muted); transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.iconBtn:disabled { opacity: 0.4; }
|
|
||||||
.iconBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.iconBtnActive:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); filter: brightness(1.1); }
|
|
||||||
|
|
||||||
.externalPanel {
|
|
||||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
|
||||||
padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
.externalHeader {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
}
|
|
||||||
.externalTitle {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.externalRow {
|
|
||||||
display: flex; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.externalInput {
|
|
||||||
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.externalInput:focus { border-color: var(--border-focus); }
|
|
||||||
.externalInput:disabled { opacity: 0.5; }
|
|
||||||
.externalInputError { border-color: var(--color-error) !important; }
|
|
||||||
.externalError {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--color-error); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
.installBtn {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 14px; border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: filter var(--t-base), opacity var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.installBtn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.installBtnSuccess {
|
|
||||||
background: var(--color-success, #2d6a3f); border-color: var(--color-success, #2d6a3f);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tabs { display: flex; gap: 2px; }
|
|
||||||
.tab {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 10px; border-radius: var(--radius-md); border: none;
|
|
||||||
background: none; color: var(--text-muted); cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
|
||||||
.tabActive { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md); padding: 5px 10px 5px 26px;
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
|
|
||||||
.group { display: flex; flex-direction: column; }
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
padding: 8px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 32px; height: 32px; border-radius: var(--radius-md);
|
|
||||||
object-fit: cover; flex-shrink: 0; background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.name {
|
|
||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.langTag {
|
|
||||||
background: var(--bg-overlay); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-sm); padding: 1px 5px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wider);
|
|
||||||
}
|
|
||||||
.nsfwTag {
|
|
||||||
background: transparent; border: 1px solid var(--color-error);
|
|
||||||
border-radius: var(--radius-sm); padding: 1px 5px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--color-error); letter-spacing: var(--tracking-wider);
|
|
||||||
}
|
|
||||||
.updateBadge {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); border-radius: var(--radius-sm);
|
|
||||||
padding: 2px 6px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.updateBadgeSmall {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--accent-fg); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowActions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.actionBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 10px; border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
}
|
|
||||||
.actionBtn:hover { filter: brightness(1.1); }
|
|
||||||
.actionBtnDim {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 10px; border-radius: var(--radius-md);
|
|
||||||
background: none; color: var(--text-faint);
|
|
||||||
border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.actionBtnDim:hover { color: var(--color-error); border-color: var(--color-error); }
|
|
||||||
|
|
||||||
.expandBtn {
|
|
||||||
display: flex; align-items: center; gap: 3px;
|
|
||||||
padding: 4px 6px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.expandBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.expandCount {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.variants {
|
|
||||||
display: flex; flex-direction: column; gap: 1px;
|
|
||||||
margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3));
|
|
||||||
padding-left: var(--sp-3);
|
|
||||||
border-left: 1px solid var(--border-dim);
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
.variantRow {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.variantRow:hover { background: var(--bg-raised); }
|
|
||||||
.variantName {
|
|
||||||
flex: 1; font-size: var(--text-sm); color: var(--text-muted);
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.variantVersion {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.variantActions { flex-shrink: 0; }
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
flex: 1; color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Panel shared styles ── */
|
|
||||||
.externalPanel {
|
|
||||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
|
||||||
padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
.panelHeader {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
}
|
|
||||||
.panelTitle {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.panelError {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--color-error); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
.externalRow { display: flex; gap: var(--sp-2); }
|
|
||||||
.externalInput {
|
|
||||||
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.externalInput:focus { border-color: var(--border-focus); }
|
|
||||||
.externalInput:disabled { opacity: 0.5; }
|
|
||||||
.externalInputError { border-color: var(--color-error) !important; }
|
|
||||||
.installBtn {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 14px; border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: filter var(--t-base), opacity var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.installBtn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.installBtnSuccess {
|
|
||||||
background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
|
|
||||||
border-color: var(--accent-fg); color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Repo list ── */
|
|
||||||
.repoLoading {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
padding: var(--sp-3);
|
|
||||||
}
|
|
||||||
.repoEmpty {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: var(--sp-1) 2px;
|
|
||||||
}
|
|
||||||
.repoList {
|
|
||||||
display: flex; flex-direction: column; gap: 2px;
|
|
||||||
}
|
|
||||||
.repoRow {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.repoUrl {
|
|
||||||
flex: 1; font-family: var(--font-mono, monospace); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
.repoRemoveBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 20px; height: 20px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.repoRemoveBtn:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
|
||||||
.repoRemoveBtn:disabled { opacity: 0.4; }
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo } from "react";
|
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import {
|
|
||||||
GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION,
|
|
||||||
GET_SETTINGS, SET_EXTENSION_REPOS,
|
|
||||||
} from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Extension } from "../../lib/types";
|
|
||||||
import s from "./ExtensionList.module.css";
|
|
||||||
|
|
||||||
type Filter = "installed" | "available" | "updates" | "all";
|
|
||||||
type Panel = null | "apk" | "repos";
|
|
||||||
|
|
||||||
function baseName(name: string): string {
|
|
||||||
return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExtGroup {
|
|
||||||
base: string;
|
|
||||||
primary: Extension;
|
|
||||||
variants: Extension[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExtensionList() {
|
|
||||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [filter, setFilter] = useState<Filter>("installed");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [working, setWorking] = useState<Set<string>>(new Set());
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
||||||
const [panel, setPanel] = useState<Panel>(null);
|
|
||||||
|
|
||||||
// APK install state
|
|
||||||
const [externalUrl, setExternalUrl] = useState("");
|
|
||||||
const [installing, setInstalling] = useState(false);
|
|
||||||
const [installError, setInstallError] = useState<string | null>(null);
|
|
||||||
const [installSuccess, setInstallSuccess] = useState(false);
|
|
||||||
|
|
||||||
// Repo management state
|
|
||||||
const [repos, setRepos] = useState<string[]>([]);
|
|
||||||
const [reposLoading, setReposLoading] = useState(false);
|
|
||||||
const [newRepoUrl, setNewRepoUrl] = useState("");
|
|
||||||
const [repoError, setRepoError] = useState<string | null>(null);
|
|
||||||
const [savingRepos, setSavingRepos] = useState(false);
|
|
||||||
|
|
||||||
const preferredLang = useStore((s) => s.settings.preferredExtensionLang);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
|
||||||
.then((d) => setExtensions(d.extensions.nodes))
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromRepo() {
|
|
||||||
setRefreshing(true);
|
|
||||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
|
||||||
.then((d) => setExtensions(d.fetchExtensions.extensions))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setRefreshing(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepos() {
|
|
||||||
setReposLoading(true);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS);
|
|
||||||
setRepos(d.settings.extensionRepos ?? []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setReposLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRepos(updated: string[]) {
|
|
||||||
setSavingRepos(true);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(
|
|
||||||
SET_EXTENSION_REPOS, { repos: updated }
|
|
||||||
);
|
|
||||||
setRepos(d.setSettings.settings.extensionRepos);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setRepoError(e instanceof Error ? e.message : "Failed to save");
|
|
||||||
} finally {
|
|
||||||
setSavingRepos(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRepo() {
|
|
||||||
const url = newRepoUrl.trim();
|
|
||||||
if (!url) return;
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
||||||
setRepoError("URL must start with http:// or https://");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (repos.includes(url)) {
|
|
||||||
setRepoError("Repo already added");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRepoError(null);
|
|
||||||
setNewRepoUrl("");
|
|
||||||
saveRepos([...repos, url]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRepo(url: string) {
|
|
||||||
saveRepos(repos.filter((r) => r !== url));
|
|
||||||
}
|
|
||||||
|
|
||||||
const mutate = async (fn: () => Promise<unknown>, pkgName: string) => {
|
|
||||||
setWorking((p) => new Set(p).add(pkgName));
|
|
||||||
await fn().catch(console.error);
|
|
||||||
await load();
|
|
||||||
setWorking((p) => { const n = new Set(p); n.delete(pkgName); return n; });
|
|
||||||
};
|
|
||||||
|
|
||||||
async function installExternal() {
|
|
||||||
const url = externalUrl.trim();
|
|
||||||
if (!url) return;
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
||||||
setInstallError("URL must start with http:// or https://");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!url.endsWith(".apk")) {
|
|
||||||
setInstallError("URL must point to an .apk file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setInstalling(true);
|
|
||||||
setInstallError(null);
|
|
||||||
setInstallSuccess(false);
|
|
||||||
try {
|
|
||||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
|
||||||
setInstallSuccess(true);
|
|
||||||
setExternalUrl("");
|
|
||||||
await load();
|
|
||||||
setTimeout(() => {
|
|
||||||
setPanel(null);
|
|
||||||
setInstallSuccess(false);
|
|
||||||
}, 1500);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setInstallError(e instanceof Error ? e.message : "Install failed");
|
|
||||||
} finally {
|
|
||||||
setInstalling(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPanel(p: Panel) {
|
|
||||||
if (panel === p) {
|
|
||||||
setPanel(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPanel(p);
|
|
||||||
setInstallError(null);
|
|
||||||
setInstallSuccess(false);
|
|
||||||
setExternalUrl("");
|
|
||||||
setRepoError(null);
|
|
||||||
setNewRepoUrl("");
|
|
||||||
if (p === "repos") loadRepos();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchFromRepo().finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filtered = extensions.filter((e) => {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
|
||||||
const matchFilter =
|
|
||||||
filter === "installed" ? e.isInstalled :
|
|
||||||
filter === "available" ? !e.isInstalled :
|
|
||||||
filter === "updates" ? e.hasUpdate : true;
|
|
||||||
return matchSearch && matchFilter;
|
|
||||||
});
|
|
||||||
|
|
||||||
const groups = useMemo<ExtGroup[]>(() => {
|
|
||||||
const map = new Map<string, Extension[]>();
|
|
||||||
for (const ext of filtered) {
|
|
||||||
const key = baseName(ext.name);
|
|
||||||
if (!map.has(key)) map.set(key, []);
|
|
||||||
map.get(key)!.push(ext);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).map(([base, all]) => {
|
|
||||||
const primary =
|
|
||||||
all.find((v) => v.lang === preferredLang) ??
|
|
||||||
all.find((v) => v.lang === "en") ??
|
|
||||||
all[0];
|
|
||||||
const variants = all.filter((v) => v.pkgName !== primary.pkgName);
|
|
||||||
return { base, primary, variants };
|
|
||||||
});
|
|
||||||
}, [filtered, preferredLang]);
|
|
||||||
|
|
||||||
const updateCount = extensions.filter((e) => e.hasUpdate).length;
|
|
||||||
|
|
||||||
const FILTERS: { id: Filter; label: string }[] = [
|
|
||||||
{ id: "installed", label: "Installed" },
|
|
||||||
{ id: "available", label: "Available" },
|
|
||||||
{ id: "updates", label: updateCount > 0 ? `Updates (${updateCount})` : "Updates" },
|
|
||||||
{ id: "all", label: "All" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function toggleExpand(base: string) {
|
|
||||||
setExpanded((p) => {
|
|
||||||
const n = new Set(p);
|
|
||||||
n.has(base) ? n.delete(base) : n.add(base);
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActions(ext: Extension) {
|
|
||||||
if (working.has(ext.pkgName))
|
|
||||||
return <CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />;
|
|
||||||
if (ext.hasUpdate) return (
|
|
||||||
<div className={s.rowActions}>
|
|
||||||
<button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, update: true }), ext.pkgName)}>Update</button>
|
|
||||||
<button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (ext.isInstalled)
|
|
||||||
return <button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>;
|
|
||||||
return <button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, install: true }), ext.pkgName)}>Install</button>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<h1 className={s.heading}>Extensions</h1>
|
|
||||||
<div className={s.headerActions}>
|
|
||||||
<button
|
|
||||||
className={[s.iconBtn, panel === "repos" ? s.iconBtnActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => openPanel("repos")} title="Manage repos">
|
|
||||||
<GitBranch size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={[s.iconBtn, panel === "apk" ? s.iconBtnActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => openPanel("apk")} title="Install from URL">
|
|
||||||
<Plus size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button className={s.iconBtn} onClick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
|
||||||
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── APK install panel ── */}
|
|
||||||
{panel === "apk" && (
|
|
||||||
<div className={s.externalPanel}>
|
|
||||||
<div className={s.panelHeader}>
|
|
||||||
<span className={s.panelTitle}>Install from APK URL</span>
|
|
||||||
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<div className={s.externalRow}>
|
|
||||||
<input
|
|
||||||
className={[s.externalInput, installError ? s.externalInputError : ""].join(" ").trim()}
|
|
||||||
placeholder="https://example.com/extension.apk"
|
|
||||||
value={externalUrl}
|
|
||||||
onChange={(e) => { setExternalUrl(e.target.value); setInstallError(null); }}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && !installing && installExternal()}
|
|
||||||
autoFocus
|
|
||||||
disabled={installing}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className={[s.installBtn, installSuccess ? s.installBtnSuccess : ""].join(" ").trim()}
|
|
||||||
onClick={installExternal}
|
|
||||||
disabled={installing || !externalUrl.trim()}
|
|
||||||
>
|
|
||||||
{installing
|
|
||||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
|
||||||
: installSuccess
|
|
||||||
? <><Check size={13} weight="bold" /> Done</>
|
|
||||||
: "Install"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{installError && <div className={s.panelError}>{installError}</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Repo management panel ── */}
|
|
||||||
{panel === "repos" && (
|
|
||||||
<div className={s.externalPanel}>
|
|
||||||
<div className={s.panelHeader}>
|
|
||||||
<span className={s.panelTitle}>Extension Repositories</span>
|
|
||||||
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{reposLoading ? (
|
|
||||||
<div className={s.repoLoading}>
|
|
||||||
<CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{repos.length === 0 ? (
|
|
||||||
<div className={s.repoEmpty}>No repos configured.</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.repoList}>
|
|
||||||
{repos.map((url) => (
|
|
||||||
<div key={url} className={s.repoRow}>
|
|
||||||
<span className={s.repoUrl}>{url}</span>
|
|
||||||
<button
|
|
||||||
className={s.repoRemoveBtn}
|
|
||||||
onClick={() => removeRepo(url)}
|
|
||||||
disabled={savingRepos}
|
|
||||||
title="Remove repo"
|
|
||||||
>
|
|
||||||
{savingRepos
|
|
||||||
? <CircleNotch size={12} weight="light" className="anim-spin" />
|
|
||||||
: <X size={12} weight="bold" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={s.externalRow} style={{ marginTop: "var(--sp-2)" }}>
|
|
||||||
<input
|
|
||||||
className={[s.externalInput, repoError ? s.externalInputError : ""].join(" ").trim()}
|
|
||||||
placeholder="https://example.com/index.min.json"
|
|
||||||
value={newRepoUrl}
|
|
||||||
onChange={(e) => { setNewRepoUrl(e.target.value); setRepoError(null); }}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
|
||||||
disabled={savingRepos}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className={s.installBtn}
|
|
||||||
onClick={addRepo}
|
|
||||||
disabled={savingRepos || !newRepoUrl.trim()}
|
|
||||||
>
|
|
||||||
{savingRepos
|
|
||||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
|
||||||
: "Add"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{repoError && <div className={s.panelError}>{repoError}</div>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={s.controls}>
|
|
||||||
<div className={s.tabs}>
|
|
||||||
{FILTERS.map((f) => (
|
|
||||||
<button key={f.id} onClick={() => setFilter(f.id)}
|
|
||||||
className={[s.tab, filter === f.id ? s.tabActive : ""].join(" ").trim()}>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={s.searchWrap}>
|
|
||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
|
||||||
<input className={s.search} placeholder="Search"
|
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
) : groups.length === 0 ? (
|
|
||||||
<div className={s.empty}>No extensions found.</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.list}>
|
|
||||||
{groups.map(({ base, primary, variants }) => {
|
|
||||||
const isExpanded = expanded.has(base);
|
|
||||||
const hasVariants = variants.length > 0;
|
|
||||||
return (
|
|
||||||
<div key={base} className={s.group}>
|
|
||||||
<div className={s.row}>
|
|
||||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} className={s.icon}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
||||||
<div className={s.info}>
|
|
||||||
<span className={s.name}>{base}</span>
|
|
||||||
<span className={s.meta}>
|
|
||||||
<span className={s.langTag}>{primary.lang.toUpperCase()}</span>
|
|
||||||
{" "}v{primary.versionName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{primary.hasUpdate && <span className={s.updateBadge}>Update</span>}
|
|
||||||
{renderActions(primary)}
|
|
||||||
{hasVariants && (
|
|
||||||
<button className={s.expandBtn} onClick={() => toggleExpand(base)}
|
|
||||||
title={`${variants.length + 1} languages`}>
|
|
||||||
{isExpanded ? <CaretDown size={12} weight="light" /> : <CaretRight size={12} weight="light" />}
|
|
||||||
<span className={s.expandCount}>{variants.length + 1}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isExpanded && hasVariants && (
|
|
||||||
<div className={s.variants}>
|
|
||||||
{variants.map((v) => (
|
|
||||||
<div key={v.pkgName} className={s.variantRow}>
|
|
||||||
<span className={s.langTag}>{v.lang.toUpperCase()}</span>
|
|
||||||
<span className={s.variantName}>{v.name}</span>
|
|
||||||
<span className={s.variantVersion}>v{v.versionName}</span>
|
|
||||||
{v.hasUpdate && <span className={s.updateBadgeSmall}>↑</span>}
|
|
||||||
<div className={s.variantActions}>{renderActions(v)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--bg-base);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
/* GPU layer for main content area */
|
|
||||||
transform: translateZ(0);
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<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>
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useStore } from "../../store";
|
|
||||||
import Sidebar from "./Sidebar";
|
|
||||||
import Library from "../pages/Library";
|
|
||||||
import SeriesDetail from "../pages/SeriesDetail";
|
|
||||||
import History from "../pages/History";
|
|
||||||
import Search from "../pages/Search";
|
|
||||||
import Explore from "../sources/Explore";
|
|
||||||
import DownloadQueue from "../downloads/DownloadQueue";
|
|
||||||
import ExtensionList from "../extensions/ExtensionList";
|
|
||||||
import s from "./Layout.module.css";
|
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
const navPage = useStore((s) => s.navPage);
|
|
||||||
const activeManga = useStore((s) => s.activeManga);
|
|
||||||
|
|
||||||
function renderContent() {
|
|
||||||
if (navPage === "library" && activeManga) return <SeriesDetail />;
|
|
||||||
switch (navPage) {
|
|
||||||
case "library": return <Library />;
|
|
||||||
case "search": return <Search />;
|
|
||||||
case "history": return <History />;
|
|
||||||
case "sources": return <Explore />;
|
|
||||||
case "explore": return <Explore />;
|
|
||||||
case "downloads": return <DownloadQueue />;
|
|
||||||
case "extensions": return <ExtensionList />;
|
|
||||||
default: return <Library />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<Sidebar />
|
|
||||||
<main className={s.main}>{renderContent()}</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
<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>
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
.root {
|
|
||||||
width: var(--sidebar-width);
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-void);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--sp-4) 0;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: var(--sp-3);
|
|
||||||
overflow: visible;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
transition: opacity var(--t-base), transform var(--t-base);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
|
||||||
.logo:active { transform: scale(0.92); }
|
|
||||||
|
|
||||||
.logoIcon {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
background-color: var(--accent);
|
|
||||||
mask-image: url("../../assets/moku-icon.svg");
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
mask-position: center;
|
|
||||||
mask-size: contain;
|
|
||||||
-webkit-mask-image: url("../../assets/moku-icon.svg");
|
|
||||||
-webkit-mask-repeat: no-repeat;
|
|
||||||
-webkit-mask-position: center;
|
|
||||||
-webkit-mask-size: contain;
|
|
||||||
filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35));
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
width: 36px; height: 36px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.tabActive { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
|
|
||||||
.bottom {
|
|
||||||
display: flex; flex-direction: column; align-items: center;
|
|
||||||
width: 100%; padding: var(--sp-3) var(--sp-2) 0;
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
margin-top: var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingsBtn {
|
|
||||||
width: 36px; height: 36px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
|
||||||
}
|
|
||||||
.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<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>
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import {
|
|
||||||
Books, DownloadSimple, PuzzlePiece, Compass,
|
|
||||||
GearSix, ClockCounterClockwise, MagnifyingGlass,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { useStore, type NavPage } from "../../store";
|
|
||||||
import s from "./Sidebar.module.css";
|
|
||||||
|
|
||||||
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
|
||||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
|
||||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
|
||||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
|
||||||
{ id: "explore", icon: <Compass size={18} weight="light" />, label: "Explore" },
|
|
||||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
|
||||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const navPage = useStore((state) => state.navPage);
|
|
||||||
const setNavPage = useStore((state) => state.setNavPage);
|
|
||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
|
||||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
|
||||||
const openSettings = useStore((state) => state.openSettings);
|
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
|
||||||
setNavPage(id);
|
|
||||||
if (id !== "explore") setActiveSource(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goHome() {
|
|
||||||
setNavPage("library");
|
|
||||||
setActiveSource(null);
|
|
||||||
setActiveManga(null);
|
|
||||||
setLibraryFilter("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className={s.root}>
|
|
||||||
{/* Logo click → back to library root */}
|
|
||||||
<button className={s.logo} onClick={goHome} title="Go to Library" aria-label="Go to Library">
|
|
||||||
<div className={s.logoIcon} />
|
|
||||||
</button>
|
|
||||||
<nav className={s.nav}>
|
|
||||||
{TABS.map((tab) => (
|
|
||||||
<button key={tab.id} title={tab.label}
|
|
||||||
onClick={() => navigate(tab.id)}
|
|
||||||
className={[s.tab, navPage === tab.id ? s.tabActive : ""].join(" ")}>
|
|
||||||
{tab.icon}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div className={s.bottom}>
|
|
||||||
<button className={s.settingsBtn} onClick={openSettings} title="Settings">
|
|
||||||
<GearSix size={18} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
<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>
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
.bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
|
||||||
background: var(--bg-void);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
user-select: none;
|
|
||||||
/* Drag region covers the whole bar */
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
/* Controls must NOT be draggable */
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnClose:hover {
|
|
||||||
color: #fff;
|
|
||||||
background: #c0392b;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<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>
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import s from "./TitleBar.module.css";
|
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
|
|
||||||
export default function TitleBar() {
|
|
||||||
return (
|
|
||||||
<div className={s.bar} data-tauri-drag-region>
|
|
||||||
<span className={s.title} data-tauri-drag-region>Moku</span>
|
|
||||||
<div className={s.controls}>
|
|
||||||
<button
|
|
||||||
className={s.btn}
|
|
||||||
onClick={() => win.minimize()}
|
|
||||||
title="Minimize"
|
|
||||||
aria-label="Minimize"
|
|
||||||
>
|
|
||||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
|
||||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" strokeWidth="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={s.btn}
|
|
||||||
onClick={() => win.toggleMaximize()}
|
|
||||||
title="Maximize"
|
|
||||||
aria-label="Maximize"
|
|
||||||
>
|
|
||||||
<svg width="9" height="9" viewBox="0 0 9 9">
|
|
||||||
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1"
|
|
||||||
fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={[s.btn, s.btnClose].join(" ")}
|
|
||||||
onClick={() => win.close()}
|
|
||||||
title="Close"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { store, dismissToast } from "../../store/state.svelte";
|
||||||
|
import type { Toast } from "../../store/state.svelte";
|
||||||
|
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
function schedule(t: Toast) {
|
||||||
|
if (timers.has(t.id)) return;
|
||||||
|
const dur = t.duration ?? 3500;
|
||||||
|
if (dur === 0) return;
|
||||||
|
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
store.toasts.forEach(schedule);
|
||||||
|
return () => timers.forEach(clearTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
const icons: Record<Toast["kind"], string> = {
|
||||||
|
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||||
|
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||||
|
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||||
|
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if store.toasts.length}
|
||||||
|
<div class="toaster" aria-live="polite">
|
||||||
|
{#each store.toasts as t (t.id)}
|
||||||
|
<div class="toast toast-{t.kind}" role="alert">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d={icons[t.kind]} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="body">
|
||||||
|
<p class="title">{t.title}</p>
|
||||||
|
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toaster {
|
||||||
|
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
|
||||||
|
z-index: 9999; display: flex; flex-direction: column;
|
||||||
|
gap: var(--sp-2); pointer-events: none; max-width: 320px;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
display: flex; align-items: flex-start; gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
||||||
|
pointer-events: all; min-width: 220px;
|
||||||
|
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
||||||
|
}
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||||
|
}
|
||||||
|
.toast-success { border-color: var(--accent-dim); }
|
||||||
|
.toast-success .icon { color: var(--accent-fg); }
|
||||||
|
.toast-error { border-color: var(--color-error); }
|
||||||
|
.toast-error .icon { color: var(--color-error); }
|
||||||
|
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
|
||||||
|
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
|
||||||
|
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
|
||||||
|
.sub {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.close {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 18px; height: 18px; border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
|
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
||||||
|
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||||
|
import type { Manga, Source } from "../../lib/types";
|
||||||
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const INITIAL_PAGES = 3;
|
||||||
|
const MAX_SOURCES = 12;
|
||||||
|
const CONCURRENCY = 4;
|
||||||
|
|
||||||
|
function parseTags(f: string): string[] { return f.split("+").map((t) => t.trim()).filter(Boolean); }
|
||||||
|
function tagsLabel(tags: string[]): string {
|
||||||
|
if (tags.length === 1) return tags[0];
|
||||||
|
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
||||||
|
}
|
||||||
|
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
||||||
|
const g = (m.genre ?? []).map((x) => x.toLowerCase());
|
||||||
|
return tags.every((t) => g.includes(t.toLowerCase()));
|
||||||
|
}
|
||||||
|
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
|
||||||
|
let i = 0;
|
||||||
|
async function worker() { while (i < items.length) { if (signal.aborted) return; await fn(items[i++]).catch(() => {}); } }
|
||||||
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
|
}
|
||||||
|
const prevNavPage = store.navPage;
|
||||||
|
const tags = $derived(parseTags(store.genreFilter));
|
||||||
|
const primaryTag = $derived(tags[0] ?? "");
|
||||||
|
const label = $derived(tagsLabel(tags));
|
||||||
|
|
||||||
|
let libraryManga: Manga[] = $state([]);
|
||||||
|
let sourceManga: Manga[] = $state([]);
|
||||||
|
let loadingInitial = $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>
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex; flex-direction: column; height: 100%;
|
|
||||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md); padding: 5px 28px 5px 26px;
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.searchClear {
|
|
||||||
position: absolute; right: 7px;
|
|
||||||
color: var(--text-faint); font-size: 14px; line-height: 1;
|
|
||||||
background: none; border: none; cursor: pointer; padding: 2px;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.searchClear:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.clearBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
|
||||||
|
|
||||||
.group { margin-bottom: var(--sp-5); }
|
|
||||||
.groupLabel {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.row:hover .playIcon { opacity: 1; }
|
|
||||||
|
|
||||||
/* Thumb with session count badge */
|
|
||||||
.thumbWrap { position: relative; flex-shrink: 0; }
|
|
||||||
.thumb {
|
|
||||||
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
|
||||||
object-fit: cover; display: block; background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.sessionBadge {
|
|
||||||
position: absolute; bottom: -4px; right: -6px;
|
|
||||||
background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
padding: 1px 4px; border-radius: 6px;
|
|
||||||
line-height: 1.4;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.mangaTitle {
|
|
||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.chapterName {
|
|
||||||
font-size: var(--text-sm); color: var(--text-muted);
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1); min-width: 0;
|
|
||||||
}
|
|
||||||
.chapterRange {
|
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
color: var(--text-muted); font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.rangeSep {
|
|
||||||
color: var(--text-faint); font-size: 10px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.pageBadge {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.time {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
|
||||||
flex-shrink: 0; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.playIcon {
|
|
||||||
color: var(--text-faint); flex-shrink: 0;
|
|
||||||
opacity: 0; transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
flex: 1; display: flex; flex-direction: column;
|
|
||||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.emptyIcon { color: var(--text-faint); }
|
|
||||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "@phosphor-icons/react";
|
|
||||||
import { thumbUrl } from "../../lib/client";
|
|
||||||
import { useStore, type HistoryEntry } from "../../store";
|
|
||||||
import s from "./History.module.css";
|
|
||||||
|
|
||||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
|
||||||
const diff = Date.now() - ts;
|
|
||||||
const m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "Just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayLabel(ts: number): string {
|
|
||||||
const d = new Date(ts);
|
|
||||||
const now = new Date();
|
|
||||||
if (d.toDateString() === now.toDateString()) return "Today";
|
|
||||||
const yesterday = new Date(now);
|
|
||||||
yesterday.setDate(now.getDate() - 1);
|
|
||||||
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
|
||||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session grouping ──────────────────────────────────────────────────────────
|
|
||||||
// Consecutive entries for the same manga within SESSION_GAP_MS are collapsed
|
|
||||||
// into one session card showing the chapter range read.
|
|
||||||
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
|
||||||
|
|
||||||
export interface ReadingSession {
|
|
||||||
mangaId: number;
|
|
||||||
mangaTitle: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
latestChapterId: number;
|
|
||||||
latestChapterName: string;
|
|
||||||
latestPageNumber: number;
|
|
||||||
firstChapterName: string;
|
|
||||||
chapterCount: number;
|
|
||||||
readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessions(entries: HistoryEntry[]): ReadingSession[] {
|
|
||||||
if (!entries.length) return [];
|
|
||||||
const sessions: ReadingSession[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < entries.length) {
|
|
||||||
const anchor = entries[i];
|
|
||||||
const group: HistoryEntry[] = [anchor];
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < entries.length) {
|
|
||||||
const next = entries[j];
|
|
||||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
|
||||||
group.push(next);
|
|
||||||
j++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const latest = group[0];
|
|
||||||
const oldest = group[group.length - 1];
|
|
||||||
sessions.push({
|
|
||||||
mangaId: latest.mangaId,
|
|
||||||
mangaTitle: latest.mangaTitle,
|
|
||||||
thumbnailUrl: latest.thumbnailUrl,
|
|
||||||
latestChapterId: latest.chapterId,
|
|
||||||
latestChapterName: latest.chapterName,
|
|
||||||
latestPageNumber: latest.pageNumber,
|
|
||||||
firstChapterName: oldest.chapterName,
|
|
||||||
chapterCount: group.length,
|
|
||||||
readAt: latest.readAt,
|
|
||||||
});
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
return sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupSessionsByDay(sessions: ReadingSession[]): { label: string; items: ReadingSession[] }[] {
|
|
||||||
const groups = new Map<string, ReadingSession[]>();
|
|
||||||
for (const sess of sessions) {
|
|
||||||
const label = dayLabel(sess.readAt);
|
|
||||||
if (!groups.has(label)) groups.set(label, []);
|
|
||||||
groups.get(label)!.push(sess);
|
|
||||||
}
|
|
||||||
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function History() {
|
|
||||||
const history = useStore((s) => s.history);
|
|
||||||
const clearHistory = useStore((s) => s.clearHistory);
|
|
||||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
|
||||||
const setNavPage = useStore((s) => s.setNavPage);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (!q) return history;
|
|
||||||
return history.filter(
|
|
||||||
(e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}, [history, search]);
|
|
||||||
|
|
||||||
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
|
||||||
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
|
||||||
|
|
||||||
function resumeReading(session: ReadingSession) {
|
|
||||||
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
|
||||||
setNavPage("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<h1 className={s.heading}>History</h1>
|
|
||||||
<div className={s.headerRight}>
|
|
||||||
<div className={s.searchWrap}>
|
|
||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
|
||||||
<input className={s.search} placeholder="Search history…"
|
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
||||||
{search && (
|
|
||||||
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{history.length > 0 && (
|
|
||||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
|
||||||
<Trash size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{history.length === 0 ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
|
||||||
<p className={s.emptyText}>No reading history yet</p>
|
|
||||||
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
|
||||||
</div>
|
|
||||||
) : sessions.length === 0 ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<Books size={28} weight="light" className={s.emptyIcon} />
|
|
||||||
<p className={s.emptyText}>No results for "{search}"</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.list}>
|
|
||||||
{groups.map(({ label, items }) => (
|
|
||||||
<div key={label} className={s.group}>
|
|
||||||
<p className={s.groupLabel}>{label}</p>
|
|
||||||
{items.map((session) => (
|
|
||||||
<button
|
|
||||||
key={`${session.latestChapterId}-${session.readAt}`}
|
|
||||||
className={s.row}
|
|
||||||
onClick={() => resumeReading(session)}
|
|
||||||
>
|
|
||||||
<div className={s.thumbWrap}>
|
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} className={s.thumb} />
|
|
||||||
{session.chapterCount > 1 && (
|
|
||||||
<span className={s.sessionBadge}>{session.chapterCount}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={s.info}>
|
|
||||||
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
|
||||||
<span className={s.chapterName}>
|
|
||||||
{session.chapterCount > 1 ? (
|
|
||||||
<span className={s.chapterRange}>
|
|
||||||
{session.firstChapterName}
|
|
||||||
<span className={s.rangeSep}>→</span>
|
|
||||||
{session.latestChapterName}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{session.latestChapterName}
|
|
||||||
{session.latestPageNumber > 1 && (
|
|
||||||
<span className={s.pageBadge}>p.{session.latestPageNumber}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className={s.time}>{timeAgo(session.readAt)}</span>
|
|
||||||
<Play size={12} weight="fill" className={s.playIcon} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
<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>
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
.root {
|
|
||||||
padding: var(--sp-6);
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
/* GPU acceleration for smooth scrolling */
|
|
||||||
will-change: scroll-position;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: var(--sp-5);
|
|
||||||
gap: var(--sp-4);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerLeft {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter tabs */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.tabActive {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabActive:hover { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.tabCount {
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: inherit;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search */
|
|
||||||
.searchWrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchIcon {
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 5px 10px 5px 28px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
width: 180px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
/* Virtual row — flexbox instead of CSS grid so virtualizer controls height */
|
|
||||||
.virtualRow {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: 0 var(--sp-6);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Individual card fills its flex slot */
|
|
||||||
.card {
|
|
||||||
flex: 1 1 130px;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 200px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghostCard {
|
|
||||||
flex: 1 1 130px;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 200px;
|
|
||||||
pointer-events: none;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
|
||||||
.card:hover .title { color: var(--text-primary); }
|
|
||||||
|
|
||||||
.coverWrap {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
/* GPU-accelerated compositing */
|
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
/* Hint to compositor */
|
|
||||||
will-change: filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloadedBadge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: var(--sp-1);
|
|
||||||
right: var(--sp-1);
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
background: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--accent-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeleton grid still uses CSS grid since it's fixed 12 items */
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-4) var(--sp-6) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeleton */
|
|
||||||
.cardSkeleton { padding: 0; }
|
|
||||||
|
|
||||||
.coverSkeletonWrap {
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleSkeleton {
|
|
||||||
height: 12px;
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
width: 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ghost cards fill trailing grid space without taking interaction */
|
|
||||||
.ghostCard {
|
|
||||||
padding: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
visibility: hidden;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 60%;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
gap: var(--sp-2);
|
|
||||||
text-align: center;
|
|
||||||
line-height: var(--leading-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
|
||||||
.errorDetail { color: var(--text-faint); font-size: var(--text-sm); }
|
|
||||||
/* ── Tag filter ── */
|
|
||||||
.tagPanel {
|
|
||||||
display: flex; flex-wrap: wrap; gap: var(--sp-1);
|
|
||||||
padding: 0 var(--sp-6) var(--sp-3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagChip {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
|
||||||
background: none; color: var(--text-faint); cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.tagChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.tagChipActive {
|
|
||||||
background: var(--accent-muted); border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.tagChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.tagClear {
|
|
||||||
display: flex; align-items: center; gap: 4px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm); border: 1px solid var(--color-error);
|
|
||||||
background: none; color: var(--color-error); cursor: pointer;
|
|
||||||
transition: background var(--t-base);
|
|
||||||
}
|
|
||||||
.tagClear:hover { background: var(--color-error-bg); }
|
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<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>
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
|
||||||
import s from "./Library.module.css";
|
|
||||||
|
|
||||||
// Keep in sync with CSS grid: minmax(130px, 1fr) + var(--sp-4)=16px gap
|
|
||||||
const CARD_MIN_W = 130;
|
|
||||||
const CARD_GAP = 16;
|
|
||||||
const ROW_HEIGHT = 260; // ~195px cover + ~40px title + 16px gap + buffer
|
|
||||||
|
|
||||||
const MangaCard = memo(function MangaCard({
|
|
||||||
manga,
|
|
||||||
onClick,
|
|
||||||
onContextMenu,
|
|
||||||
cropCovers,
|
|
||||||
}: {
|
|
||||||
manga: Manga;
|
|
||||||
onClick: () => void;
|
|
||||||
onContextMenu: (e: React.MouseEvent) => void;
|
|
||||||
cropCovers: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(manga.thumbnailUrl)}
|
|
||||||
alt={manga.title}
|
|
||||||
className={s.cover}
|
|
||||||
style={{ objectFit: cropCovers ? "cover" : "contain" }}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
{!!manga.downloadCount && (
|
|
||||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className={s.title}>{manga.title}</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function Library() {
|
|
||||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
|
||||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
|
||||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
|
||||||
const settings = useStore((state) => state.settings);
|
|
||||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
|
||||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
|
||||||
const folders = useStore((state) => state.settings.folders);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
|
|
||||||
.then((lib) => setAllManga(lib.mangas.nodes))
|
|
||||||
.catch((e) => setError(e.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset scroll when filter/search changes
|
|
||||||
useEffect(() => {
|
|
||||||
scrollRef.current?.scrollTo({ top: 0 });
|
|
||||||
}, [libraryFilter, search]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
|
||||||
if (activeFolder && !activeFolder.showTab) setLibraryFilter("library");
|
|
||||||
}, [folders]);
|
|
||||||
|
|
||||||
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
let items = allManga;
|
|
||||||
if (libraryFilter === "library") {
|
|
||||||
items = items.filter((m) => m.inLibrary);
|
|
||||||
} else if (libraryFilter === "downloaded") {
|
|
||||||
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
|
||||||
} else if (!isBuiltinFilter) {
|
|
||||||
const folder = folders.find((f) => f.id === libraryFilter);
|
|
||||||
if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id));
|
|
||||||
}
|
|
||||||
if (libraryTagFilter.length > 0)
|
|
||||||
items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag)));
|
|
||||||
if (search.trim()) {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
|
||||||
|
|
||||||
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
|
||||||
// We need to know columns to chunk filtered into rows.
|
|
||||||
// Use a ResizeObserver on the scroll container to get real width.
|
|
||||||
const [containerWidth, setContainerWidth] = useState(800);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const ro = new ResizeObserver(([entry]) => {
|
|
||||||
setContainerWidth(entry.contentRect.width);
|
|
||||||
});
|
|
||||||
ro.observe(el);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
|
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
|
||||||
const result: Manga[][] = [];
|
|
||||||
for (let i = 0; i < filtered.length; i += cols)
|
|
||||||
result.push(filtered.slice(i, i + cols));
|
|
||||||
return result;
|
|
||||||
}, [filtered, cols]);
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: rows.length,
|
|
||||||
getScrollElement: () => scrollRef.current,
|
|
||||||
estimateSize: () => ROW_HEIGHT,
|
|
||||||
overscan: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCardClick = useCallback(
|
|
||||||
(m: Manga) => () => setActiveManga(m),
|
|
||||||
[setActiveManga]
|
|
||||||
);
|
|
||||||
|
|
||||||
async function removeFromLibrary(manga: Manga) {
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
|
||||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
|
||||||
try {
|
|
||||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
|
||||||
const ids = data.chapters.nodes.filter((c) => c.isDownloaded).map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
|
||||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault();
|
|
||||||
const x = Math.min(e.clientX, window.innerWidth - 208);
|
|
||||||
const y = Math.min(e.clientY, window.innerHeight - 168);
|
|
||||||
setCtx({ x, y, manga: m });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "Open",
|
|
||||||
icon: <BookOpen size={13} weight="light" />,
|
|
||||||
onClick: () => setActiveManga(m),
|
|
||||||
},
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: m.inLibrary ? "Remove from library" : "Add to library",
|
|
||||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
|
||||||
danger: m.inLibrary,
|
|
||||||
onClick: () => m.inLibrary
|
|
||||||
? removeFromLibrary(m)
|
|
||||||
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
|
||||||
.then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
|
|
||||||
.catch(console.error),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Delete all downloads",
|
|
||||||
icon: <Trash size={13} weight="light" />,
|
|
||||||
danger: true,
|
|
||||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
|
||||||
onClick: () => deleteAllDownloads(m),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTags = useMemo(() => {
|
|
||||||
const tagSet = new Set<string>();
|
|
||||||
allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g)));
|
|
||||||
return Array.from(tagSet).sort();
|
|
||||||
}, [allManga]);
|
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
const result: Record<string, number> = {
|
|
||||||
all: allManga.length,
|
|
||||||
library: allManga.filter((m) => m.inLibrary).length,
|
|
||||||
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
|
||||||
};
|
|
||||||
folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; });
|
|
||||||
return result;
|
|
||||||
}, [allManga, folders]);
|
|
||||||
|
|
||||||
if (error) return (
|
|
||||||
<div className={s.center}>
|
|
||||||
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
|
||||||
<p className={s.errorDetail}>{error}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root} ref={scrollRef}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<div className={s.headerLeft}>
|
|
||||||
<h1 className={s.heading}>Library</h1>
|
|
||||||
<div className={s.tabs}>
|
|
||||||
{(["library", "downloaded", "all"] as const).map((f) => (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => setLibraryFilter(f)}
|
|
||||||
>
|
|
||||||
{f === "library" ? (
|
|
||||||
<><Books size={11} weight="bold" /> Saved</>
|
|
||||||
) : f === "downloaded" ? (
|
|
||||||
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
|
||||||
) : <>All</>}
|
|
||||||
<span className={s.tabCount}>{counts[f]}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{folders.filter((f) => f.showTab).map((folder) => (
|
|
||||||
<button
|
|
||||||
key={folder.id}
|
|
||||||
className={[s.tab, libraryFilter === folder.id ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => setLibraryFilter(folder.id)}
|
|
||||||
>
|
|
||||||
<Folder size={11} weight="bold" />
|
|
||||||
{folder.name}
|
|
||||||
<span className={s.tabCount}>{counts[folder.id] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={s.searchWrap}>
|
|
||||||
<MagnifyingGlass size={13} className={s.searchIcon} weight="light" />
|
|
||||||
<input
|
|
||||||
className={s.search}
|
|
||||||
placeholder="Search"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allTags.length > 0 && (
|
|
||||||
<div className={s.tagPanel}>
|
|
||||||
{libraryTagFilter.length > 0 && (
|
|
||||||
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
|
||||||
<X size={11} weight="bold" /> Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{allTags.map((tag) => {
|
|
||||||
const active = libraryTagFilter.includes(tag);
|
|
||||||
return (
|
|
||||||
<button key={tag}
|
|
||||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
|
||||||
onClick={() => setLibraryTagFilter(
|
|
||||||
active ? libraryTagFilter.filter((t) => t !== tag) : [...libraryTagFilter, tag]
|
|
||||||
)}>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className={s.grid}>
|
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.cardSkeleton}>
|
|
||||||
<div className={[s.coverSkeletonWrap, "skeleton"].join(" ")} />
|
|
||||||
<div className={[s.titleSkeleton, "skeleton"].join(" ")} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className={s.center}>
|
|
||||||
{libraryFilter === "library"
|
|
||||||
? "No manga saved to library. Browse sources to add some."
|
|
||||||
: libraryFilter === "downloaded"
|
|
||||||
? "No downloaded manga."
|
|
||||||
: !isBuiltinFilter
|
|
||||||
? "No manga in this folder yet. Right-click manga to assign them."
|
|
||||||
: "No manga found."}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Virtual scroll container */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: virtualizer.getTotalSize(),
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
||||||
const rowManga = rows[virtualRow.index];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={virtualRow.key}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: virtualRow.start,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: virtualRow.size,
|
|
||||||
}}
|
|
||||||
className={s.virtualRow}
|
|
||||||
>
|
|
||||||
{rowManga.map((m) => (
|
|
||||||
<MangaCard
|
|
||||||
key={m.id}
|
|
||||||
manga={m}
|
|
||||||
onClick={handleCardClick(m)}
|
|
||||||
onContextMenu={(e) => openCtx(e, m)}
|
|
||||||
cropCovers={settings.libraryCropCovers}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{/* Ghost cards on last row to fill grid */}
|
|
||||||
{virtualRow.index === rows.length - 1 &&
|
|
||||||
Array.from({ length: cols - rowManga.length }).map((_, i) => (
|
|
||||||
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ctx && (
|
|
||||||
<ContextMenu
|
|
||||||
x={ctx.x}
|
|
||||||
y={ctx.y}
|
|
||||||
items={buildCtxItems(ctx.manga)}
|
|
||||||
onClose={() => setCtx(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 200;
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
width: 520px;
|
|
||||||
max-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-5);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalTitle {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalTitleLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalTitleManga {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.closeBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
/* ── Steps ── */
|
|
||||||
.steps {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
padding: var(--sp-3) var(--sp-5);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
opacity: 0.4;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepActive { opacity: 1; }
|
|
||||||
.stepDone { opacity: 0.6; }
|
|
||||||
|
|
||||||
.stepDot {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepActive .stepDot {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepActive .stepLabel { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.steps .step + .step::before {
|
|
||||||
content: "›";
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-right: var(--sp-1);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Body ── */
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.centered {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--sp-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Source list ── */
|
|
||||||
.sourceList {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--sp-2);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 9px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: none;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.sourceRow:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.sourceRowActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
|
||||||
|
|
||||||
.sourceIcon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceInfo { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
|
||||||
|
|
||||||
.sourceName {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceMeta {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceArrow {
|
|
||||||
color: var(--text-faint);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.sourceRow:hover .sourceArrow { opacity: 1; }
|
|
||||||
|
|
||||||
/* ── Search step ── */
|
|
||||||
.searchStep {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBar {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 0 var(--sp-3) 0 var(--sp-2);
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
flex: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
padding: 7px 0;
|
|
||||||
}
|
|
||||||
.searchInput::placeholder { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.searchBtn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
}
|
|
||||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.backBtn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.backBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.backBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.results {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 7px var(--sp-2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.resultRow:hover:not(:disabled) { background: var(--bg-raised); }
|
|
||||||
.resultRow:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.resultCoverWrap {
|
|
||||||
width: 36px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultCover { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
|
|
||||||
.resultTitle {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeletons */
|
|
||||||
.skResult {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 7px var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skCover {
|
|
||||||
width: 36px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skMeta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.skTitle { height: 13px; width: 65%; border-radius: var(--radius-sm); }
|
|
||||||
|
|
||||||
/* ── Confirm step ── */
|
|
||||||
.confirmStep {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-4) var(--sp-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmManga {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex: 1;
|
|
||||||
max-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmCoverWrap {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 2/3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmCover { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
|
|
||||||
.confirmTitle {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmSource {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmArrow { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.confirmStats {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statRow {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statVal {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmNote {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
line-height: var(--leading-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmActions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migrateBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 7px 16px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-dim);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.migrateBtn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
|
||||||
.migrateBtn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-error);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
<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>
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning } from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
|
||||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
|
||||||
import s from "./MigrateModal.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
manga: Manga;
|
|
||||||
currentChapters: Chapter[];
|
|
||||||
onClose: () => void;
|
|
||||||
onMigrated: (newManga: Manga) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Step = "source" | "search" | "confirm";
|
|
||||||
|
|
||||||
interface Match {
|
|
||||||
manga: Manga;
|
|
||||||
chapters: Chapter[];
|
|
||||||
readCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
|
|
||||||
const [step, setStep] = useState<Step>("source");
|
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
|
||||||
const [loadingSources, setLoadingSources] = useState(true);
|
|
||||||
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
|
||||||
const [query, setQuery] = useState(manga.title);
|
|
||||||
const [results, setResults] = useState<Manga[]>([]);
|
|
||||||
const [searching, setSearching] = useState(false);
|
|
||||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
|
||||||
const [loadingMatch, setLoadingMatch] = useState(false);
|
|
||||||
const [migrating, setMigrating] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then((d) => setSources(d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id)))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoadingSources(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function searchSource() {
|
|
||||||
if (!selectedSource || !query.trim()) return;
|
|
||||||
setSearching(true);
|
|
||||||
setResults([]);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: selectedSource.id, type: "SEARCH", page: 1, query: query.trim(),
|
|
||||||
});
|
|
||||||
setResults(d.fetchSourceManga.mangas);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
} finally {
|
|
||||||
setSearching(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectMatch(m: Manga) {
|
|
||||||
setLoadingMatch(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
|
||||||
const chapters = d.fetchChapters.chapters;
|
|
||||||
const readCount = chapters.filter((c) => {
|
|
||||||
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
|
||||||
return old?.isRead;
|
|
||||||
}).length;
|
|
||||||
setSelectedMatch({ manga: m, chapters, readCount });
|
|
||||||
setStep("confirm");
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
} finally {
|
|
||||||
setLoadingMatch(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrate() {
|
|
||||||
if (!selectedMatch) return;
|
|
||||||
setMigrating(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
|
||||||
|
|
||||||
// Build read/bookmark/progress maps from old chapters keyed by chapterNumber
|
|
||||||
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
|
||||||
|
|
||||||
const toMarkRead: number[] = [];
|
|
||||||
const toMarkBookmarked: number[] = [];
|
|
||||||
const progressUpdates: { id: number; lastPageRead: number }[] = [];
|
|
||||||
|
|
||||||
for (const nc of newChapters) {
|
|
||||||
const key = Math.round(nc.chapterNumber * 100);
|
|
||||||
const old = oldByNum.get(key);
|
|
||||||
if (!old) continue;
|
|
||||||
if (old.isRead) toMarkRead.push(nc.id);
|
|
||||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
|
||||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead) {
|
|
||||||
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate read state
|
|
||||||
if (toMarkRead.length) {
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
|
||||||
}
|
|
||||||
// Migrate bookmarks
|
|
||||||
if (toMarkBookmarked.length) {
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
|
||||||
}
|
|
||||||
// Migrate in-progress pages one by one (different lastPageRead per chapter)
|
|
||||||
for (const { id, lastPageRead } of progressUpdates) {
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new to library, remove old
|
|
||||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
|
||||||
|
|
||||||
onMigrated({ ...newManga, inLibrary: true });
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
setMigrating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const readCount = currentChapters.filter((c) => c.isRead).length;
|
|
||||||
const totalCount = currentChapters.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
||||||
<div className={s.modal}>
|
|
||||||
<div className={s.modalHeader}>
|
|
||||||
<div className={s.modalTitle}>
|
|
||||||
<span className={s.modalTitleLabel}>Migrate source</span>
|
|
||||||
<span className={s.modalTitleManga}>{manga.title}</span>
|
|
||||||
</div>
|
|
||||||
<button className={s.closeBtn} onClick={onClose}>
|
|
||||||
<X size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Step indicators ── */}
|
|
||||||
<div className={s.steps}>
|
|
||||||
{(["source", "search", "confirm"] as Step[]).map((st, i) => (
|
|
||||||
<div key={st} className={[s.step, step === st ? s.stepActive : "", i < ["source","search","confirm"].indexOf(step) ? s.stepDone : ""].join(" ").trim()}>
|
|
||||||
<span className={s.stepDot}>{i < ["source","search","confirm"].indexOf(step) ? <Check size={9} weight="bold" /> : i + 1}</span>
|
|
||||||
<span className={s.stepLabel}>{st.charAt(0).toUpperCase() + st.slice(1)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.body}>
|
|
||||||
{/* ── Step 1: Pick source ── */}
|
|
||||||
{step === "source" && (
|
|
||||||
<div className={s.sourceList}>
|
|
||||||
{loadingSources ? (
|
|
||||||
<div className={s.centered}>
|
|
||||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
) : sources.length === 0 ? (
|
|
||||||
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
|
||||||
) : (
|
|
||||||
sources.map((src) => (
|
|
||||||
<button
|
|
||||||
key={src.id}
|
|
||||||
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => { setSelectedSource(src); setStep("search"); searchSource(); }}
|
|
||||||
>
|
|
||||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
||||||
<div className={s.sourceInfo}>
|
|
||||||
<span className={s.sourceName}>{src.displayName}</span>
|
|
||||||
<span className={s.sourceMeta}>{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
|
||||||
</div>
|
|
||||||
<ArrowRight size={13} weight="light" className={s.sourceArrow} />
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 2: Search & pick match ── */}
|
|
||||||
{step === "search" && (
|
|
||||||
<div className={s.searchStep}>
|
|
||||||
<div className={s.searchRow}>
|
|
||||||
<div className={s.searchBar}>
|
|
||||||
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
|
||||||
<input
|
|
||||||
className={s.searchInput}
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && searchSource()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button className={s.searchBtn} onClick={searchSource} disabled={searching}>
|
|
||||||
{searching ? <CircleNotch size={13} weight="light" className="anim-spin" /> : "Search"}
|
|
||||||
</button>
|
|
||||||
<button className={s.backBtn} onClick={() => { setStep("source"); setResults([]); }}>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
|
||||||
|
|
||||||
<div className={s.results}>
|
|
||||||
{searching && Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.skResult}>
|
|
||||||
<div className={["skeleton", s.skCover].join(" ")} />
|
|
||||||
<div className={s.skMeta}>
|
|
||||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!searching && results.map((m) => (
|
|
||||||
<button
|
|
||||||
key={m.id}
|
|
||||||
className={s.resultRow}
|
|
||||||
onClick={() => selectMatch(m)}
|
|
||||||
disabled={loadingMatch}
|
|
||||||
>
|
|
||||||
<div className={s.resultCoverWrap}>
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
|
||||||
</div>
|
|
||||||
<span className={s.resultTitle}>{m.title}</span>
|
|
||||||
{loadingMatch && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{!searching && results.length === 0 && query && (
|
|
||||||
<div className={s.centered}><span className={s.hint}>No results.</span></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 3: Confirm ── */}
|
|
||||||
{step === "confirm" && selectedMatch && (
|
|
||||||
<div className={s.confirmStep}>
|
|
||||||
<div className={s.confirmRow}>
|
|
||||||
<div className={s.confirmManga}>
|
|
||||||
<div className={s.confirmCoverWrap}>
|
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.confirmCover} />
|
|
||||||
</div>
|
|
||||||
<p className={s.confirmTitle}>{manga.title}</p>
|
|
||||||
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ArrowRight size={20} weight="light" className={s.confirmArrow} />
|
|
||||||
|
|
||||||
<div className={s.confirmManga}>
|
|
||||||
<div className={s.confirmCoverWrap}>
|
|
||||||
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} className={s.confirmCover} />
|
|
||||||
</div>
|
|
||||||
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
|
||||||
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.confirmStats}>
|
|
||||||
<div className={s.statRow}>
|
|
||||||
<span className={s.statLabel}>Chapters on new source</span>
|
|
||||||
<span className={s.statVal}>{selectedMatch.chapters.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.statRow}>
|
|
||||||
<span className={s.statLabel}>Read progress to migrate</span>
|
|
||||||
<span className={s.statVal}>{readCount} / {totalCount} chapters</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.statRow}>
|
|
||||||
<span className={s.statLabel}>Matched chapters</span>
|
|
||||||
<span className={s.statVal}>{selectedMatch.readCount} will carry over</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className={s.confirmNote}>
|
|
||||||
The current entry will be removed from your library. Downloads are not transferred.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
|
||||||
|
|
||||||
<div className={s.confirmActions}>
|
|
||||||
<button className={s.backBtn} onClick={() => setStep("search")} disabled={migrating}>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
|
||||||
{migrating
|
|
||||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
|
||||||
: "Migrate"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
.root {
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
background: #000;
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
z-index: var(--z-reader);
|
|
||||||
transform: translateZ(0); will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── UI autohide ── */
|
|
||||||
.uiHidden {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
.topbar, .bottombar {
|
|
||||||
transition: opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Topbar ── */
|
|
||||||
.topbar {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1);
|
|
||||||
padding: 0 var(--sp-3); height: 40px;
|
|
||||||
background: var(--bg-void); border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0; overflow: visible;
|
|
||||||
position: relative; z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-muted); flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.iconBtn:disabled { opacity: 0.2; cursor: default; }
|
|
||||||
|
|
||||||
.chLabel {
|
|
||||||
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
font-size: var(--text-sm); color: var(--text-muted);
|
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.chTitle { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
|
||||||
.chSep { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.pageLabel {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topSep {
|
|
||||||
width: 1px; height: 16px;
|
|
||||||
background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modeBtn {
|
|
||||||
display: flex; align-items: center; gap: 4px;
|
|
||||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-muted); flex-shrink: 0;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.modeBtn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.modeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.modeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.modeBtnLabel { text-transform: capitalize; }
|
|
||||||
|
|
||||||
/* ── Zoom ── */
|
|
||||||
.zoomWrap {
|
|
||||||
position: relative; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoomBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
|
||||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
|
||||||
min-width: 36px; text-align: center;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.zoomBtn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.zoomPopover {
|
|
||||||
position: absolute; top: calc(100% + 6px); left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2);
|
|
||||||
display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
||||||
z-index: 100; min-width: 160px;
|
|
||||||
animation: scaleIn 0.1s ease both; transform-origin: top center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoomSlider {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 140px; height: 3px;
|
|
||||||
background: var(--border-strong);
|
|
||||||
border-radius: 2px; outline: none; cursor: pointer;
|
|
||||||
}
|
|
||||||
.zoomSlider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 12px; height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-fg);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.zoomSlider::-moz-range-thumb {
|
|
||||||
width: 12px; height: 12px;
|
|
||||||
border-radius: 50%; border: none;
|
|
||||||
background: var(--accent-fg);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoomResetBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 2px var(--sp-2); border-radius: var(--radius-sm);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.zoomResetBtn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
|
||||||
|
|
||||||
/* ── Viewer ── */
|
|
||||||
.viewer {
|
|
||||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewerStrip {
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: var(--sp-4) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Images ── */
|
|
||||||
.img {
|
|
||||||
display: block; user-select: none;
|
|
||||||
image-rendering: auto;
|
|
||||||
}
|
|
||||||
.img.optimizeContrast { image-rendering: -webkit-optimize-contrast; }
|
|
||||||
|
|
||||||
/* Fit modes */
|
|
||||||
.fitWidth { max-width: var(--max-page-width); width: 100%; height: auto; }
|
|
||||||
.fitHeight { max-height: calc(100vh - 80px); width: auto; max-width: 100%; }
|
|
||||||
.fitScreen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; }
|
|
||||||
.fitOriginal { max-width: none; width: auto; height: auto; }
|
|
||||||
|
|
||||||
/* Longstrip */
|
|
||||||
.stripGap { margin-bottom: 8px; }
|
|
||||||
|
|
||||||
/* ── Double page ── */
|
|
||||||
.doubleWrap {
|
|
||||||
display: flex; align-items: flex-start; justify-content: center;
|
|
||||||
max-width: calc(var(--max-page-width) * 2);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.pageHalf { flex: 1; min-width: 0; object-fit: contain; }
|
|
||||||
.gapLeft { margin-right: 2px; }
|
|
||||||
.gapRight { margin-left: 2px; }
|
|
||||||
|
|
||||||
/* ── Bottom nav ── */
|
|
||||||
.bottombar {
|
|
||||||
display: flex; align-items: center; justify-content: center; gap: var(--sp-4);
|
|
||||||
padding: var(--sp-3); border-top: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-void); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 34px; height: 34px; border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-strong); color: var(--text-muted);
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.navBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
|
||||||
.navBtn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
|
|
||||||
/* ── States ── */
|
|
||||||
.center {
|
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
||||||
position: fixed; inset: 0; background: #000;
|
|
||||||
}
|
|
||||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
|
||||||
|
|
||||||
/* ── Download modal ── */
|
|
||||||
.dlBackdrop {
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
z-index: calc(var(--z-reader) + 10);
|
|
||||||
display: flex; align-items: flex-start; justify-content: flex-end;
|
|
||||||
padding: 48px var(--sp-4) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlModal {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-xl); padding: var(--sp-3);
|
|
||||||
min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1);
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
|
||||||
animation: scaleIn 0.12s ease both; transform-origin: top right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlTitle {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2);
|
|
||||||
border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlOption {
|
|
||||||
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
|
|
||||||
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm); color: var(--text-secondary);
|
|
||||||
background: none; border: none; cursor: pointer; text-align: left;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
}
|
|
||||||
.dlOption:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.dlOption:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.dlSub { font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
|
|
||||||
.dlRow { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.dlStepper {
|
|
||||||
display: flex; align-items: center; gap: 2px;
|
|
||||||
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlStepBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 22px; height: 28px;
|
|
||||||
font-size: var(--text-base); color: var(--text-muted);
|
|
||||||
background: none; border: none; cursor: pointer; line-height: 1;
|
|
||||||
transition: color var(--t-fast), background var(--t-fast);
|
|
||||||
}
|
|
||||||
.dlStepBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.dlStepBtn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
|
|
||||||
.dlStepVal {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary); min-width: 24px; text-align: center;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
/* Viewer focus — suppress outline since we're handling keys ourselves */
|
|
||||||
.viewer:focus { outline: none; }
|
|
||||||
@@ -1,967 +0,0 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
|
|
||||||
Square, Rows, Download, ArrowsLeftRight,
|
|
||||||
ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import {
|
|
||||||
FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ,
|
|
||||||
ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD,
|
|
||||||
} from "../../lib/queries";
|
|
||||||
import { useStore, type FitMode } from "../../store";
|
|
||||||
import { matchesKeybind, toggleFullscreen } from "../../lib/keybinds";
|
|
||||||
import s from "./Reader.module.css";
|
|
||||||
|
|
||||||
// ── LRU image cache ───────────────────────────────────────────────────────────
|
|
||||||
// Keeps browser memory in check by revoking object-URLs for chapters that
|
|
||||||
// have scrolled far away. We cache by chapterId (not URL) so that we can
|
|
||||||
// drop a whole chapter at once.
|
|
||||||
const MAX_CACHED_CHAPTERS = 6;
|
|
||||||
|
|
||||||
// Track insertion order so we can evict the oldest chapter.
|
|
||||||
const chapterCacheOrder: number[] = [];
|
|
||||||
|
|
||||||
function touchChapterOrder(chapterId: number) {
|
|
||||||
const idx = chapterCacheOrder.indexOf(chapterId);
|
|
||||||
if (idx !== -1) chapterCacheOrder.splice(idx, 1);
|
|
||||||
chapterCacheOrder.push(chapterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function evictOldestChapter(
|
|
||||||
pageCache: React.MutableRefObject<Map<number, string[]>>,
|
|
||||||
keepIds: Set<number>,
|
|
||||||
): number | null {
|
|
||||||
for (let i = 0; i < chapterCacheOrder.length; i++) {
|
|
||||||
const id = chapterCacheOrder[i];
|
|
||||||
if (!keepIds.has(id)) {
|
|
||||||
chapterCacheOrder.splice(i, 1);
|
|
||||||
pageCache.current.delete(id);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fire-and-forget: create an Image and let the browser cache it. */
|
|
||||||
function preloadImage(url: string) {
|
|
||||||
const img = new Image();
|
|
||||||
img.src = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a single image fully before resolving.
|
|
||||||
* Used to avoid showing a half-painted page.
|
|
||||||
*/
|
|
||||||
function decodeImage(url: string): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); };
|
|
||||||
img.onerror = () => resolve(); // don't block on error
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function measureAspect(url: string): Promise<number> {
|
|
||||||
return new Promise((res) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => res(img.naturalWidth / img.naturalHeight);
|
|
||||||
img.onerror = () => res(0.67);
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Download modal ────────────────────────────────────────────────────────────
|
|
||||||
function DownloadModal({
|
|
||||||
chapter,
|
|
||||||
remaining,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
chapter: { id: number; name: string };
|
|
||||||
remaining: { id: number }[];
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [nextN, setNextN] = useState(5);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const run = async (fn: () => Promise<unknown>) => {
|
|
||||||
setBusy(true);
|
|
||||||
await fn().catch(console.error);
|
|
||||||
setBusy(false);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.dlBackdrop} onClick={onClose}>
|
|
||||||
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<p className={s.dlTitle}>Download</p>
|
|
||||||
<button className={s.dlOption} disabled={busy}
|
|
||||||
onClick={() => run(() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }))}>
|
|
||||||
This chapter
|
|
||||||
<span className={s.dlSub}>{chapter.name}</span>
|
|
||||||
</button>
|
|
||||||
<div className={s.dlRow}>
|
|
||||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
|
||||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
|
||||||
chapterIds: remaining.slice(0, nextN).map((c) => c.id),
|
|
||||||
}))}>
|
|
||||||
Next chapters
|
|
||||||
<span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span>
|
|
||||||
</button>
|
|
||||||
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button className={s.dlStepBtn}
|
|
||||||
onClick={() => setNextN((n) => Math.max(1, n - 1))}
|
|
||||||
disabled={nextN <= 1}>−</button>
|
|
||||||
<span className={s.dlStepVal}>{nextN}</span>
|
|
||||||
<button className={s.dlStepBtn}
|
|
||||||
onClick={() => setNextN((n) => Math.min(remaining.length || 1, n + 1))}
|
|
||||||
disabled={nextN >= remaining.length}>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
|
||||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
|
||||||
chapterIds: remaining.map((c) => c.id),
|
|
||||||
}))}>
|
|
||||||
All remaining
|
|
||||||
<span className={s.dlSub}>{remaining.length} chapters</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Zoom slider popover ───────────────────────────────────────────────────────
|
|
||||||
function ZoomPopover({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
onReset,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
onChange: (v: number) => void;
|
|
||||||
onReset: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
return () => document.removeEventListener("mousedown", handler);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.zoomPopover} ref={ref}>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className={s.zoomSlider}
|
|
||||||
min={200}
|
|
||||||
max={2400}
|
|
||||||
step={50}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<button className={s.zoomResetBtn} onClick={onReset}>
|
|
||||||
{Math.round((value / 900) * 100)}%
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reader ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** One chapter's worth of pages in the infinite strip */
|
|
||||||
interface StripChapter {
|
|
||||||
chapterId: number;
|
|
||||||
chapterName: string;
|
|
||||||
urls: string[];
|
|
||||||
/** Global page index offset for pages in this strip chunk */
|
|
||||||
startGlobalIdx: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Reader() {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const rafRef = useRef(0);
|
|
||||||
const pageNumRef = useRef(1);
|
|
||||||
const pageCache = useRef<Map<number, string[]>>(new Map());
|
|
||||||
const aspectCache = useRef<Map<string, number>>(new Map());
|
|
||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const uiRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Track which chapters are being fetched so we don't double-fire
|
|
||||||
const fetchingRef = useRef<Set<number>>(new Set());
|
|
||||||
// Whether we've already appended the next chapter into the strip
|
|
||||||
const appendedRef = useRef<Set<number>>(new Set());
|
|
||||||
// The chapter id whose pages are currently being loaded (prevents stale sets)
|
|
||||||
const loadingChapterRef = useRef<number | null>(null);
|
|
||||||
// Mirror of stripChapters in a ref so the scroll handler never closes over stale state
|
|
||||||
const stripChaptersRef = useRef<StripChapter[]>([]);
|
|
||||||
// Scroll anchor: captured just before a head-trim so useLayoutEffect can restore position
|
|
||||||
const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [dlOpen, setDlOpen] = useState(false);
|
|
||||||
const [zoomOpen, setZoomOpen] = useState(false);
|
|
||||||
const [uiVisible, setUiVisible] = useState(true);
|
|
||||||
const markedReadRef = useRef<Set<number>>(new Set());
|
|
||||||
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
|
||||||
// True only after the first page of the new chapter has been decoded,
|
|
||||||
// preventing any flash of the previous chapter's image.
|
|
||||||
const [pageReady, setPageReady] = useState(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The infinite strip: an ordered list of chapter chunks.
|
|
||||||
* In non-longstrip modes this is unused — only pageUrls matters.
|
|
||||||
*/
|
|
||||||
const [stripChapters, setStripChapters] = useState<StripChapter[]>([]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In longstrip autoNext mode, this tracks which chapter the user is
|
|
||||||
* currently reading (for topbar display) without triggering a full reload.
|
|
||||||
*/
|
|
||||||
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Keep the ref mirror in sync so the scroll handler always sees current strip state
|
|
||||||
useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]);
|
|
||||||
|
|
||||||
// Restore scroll position synchronously after a head-trim, before the browser paints
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const anchor = scrollAnchorRef.current;
|
|
||||||
if (!anchor || !containerRef.current) return;
|
|
||||||
scrollAnchorRef.current = null;
|
|
||||||
const gained = containerRef.current.scrollHeight - anchor.scrollHeight;
|
|
||||||
// gained is negative when we removed nodes (scrollHeight shrank)
|
|
||||||
// We want scrollTop to decrease by the same amount so the visible content stays put.
|
|
||||||
// But since we removed nodes from the top, scrollHeight already shrank —
|
|
||||||
// we just need to subtract the removed pixel height from scrollTop.
|
|
||||||
if (gained < 0) {
|
|
||||||
containerRef.current.scrollTop = Math.max(0, anchor.scrollTop + gained);
|
|
||||||
}
|
|
||||||
}, [stripChapters]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
activeManga, activeChapter, activeChapterList,
|
|
||||||
pageUrls, pageNumber, settings,
|
|
||||||
setPageUrls, setPageNumber, closeReader, openReader, openSettings,
|
|
||||||
updateSettings, addHistory,
|
|
||||||
} = useStore();
|
|
||||||
|
|
||||||
const kb = settings.keybinds;
|
|
||||||
const rtl = settings.readingDirection === "rtl";
|
|
||||||
const fit = settings.fitMode ?? "width";
|
|
||||||
const style = settings.pageStyle ?? "single";
|
|
||||||
const maxW = settings.maxPageWidth ?? 900;
|
|
||||||
const autoNext = settings.autoNextChapter ?? false;
|
|
||||||
|
|
||||||
useEffect(() => { pageNumRef.current = pageNumber; }, [pageNumber]);
|
|
||||||
|
|
||||||
// ── UI autohide ──────────────────────────────────────────────────────────────
|
|
||||||
const scheduleHide = useCallback(() => {
|
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
|
||||||
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showUi = useCallback(() => {
|
|
||||||
setUiVisible(true);
|
|
||||||
scheduleHide();
|
|
||||||
}, [scheduleHide]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scheduleHide();
|
|
||||||
return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Auto-focus viewer so spacebar/arrows work ───────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
containerRef.current?.focus({ preventScroll: true });
|
|
||||||
}, [activeChapter?.id]);
|
|
||||||
|
|
||||||
// ── Fetch helpers ────────────────────────────────────────────────────────────
|
|
||||||
const fetchPages = useCallback(async (chapterId: number): Promise<string[]> => {
|
|
||||||
const cached = pageCache.current.get(chapterId);
|
|
||||||
if (cached) {
|
|
||||||
touchChapterOrder(chapterId);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
if (fetchingRef.current.has(chapterId)) {
|
|
||||||
// Poll until another in-flight fetch resolves
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const c = pageCache.current.get(chapterId);
|
|
||||||
if (c) { clearInterval(interval); resolve(c); }
|
|
||||||
}, 50);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
fetchingRef.current.add(chapterId);
|
|
||||||
const d = await gql<{ fetchChapterPages: { pages: string[] } }>(
|
|
||||||
FETCH_CHAPTER_PAGES, { chapterId }
|
|
||||||
);
|
|
||||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
|
||||||
pageCache.current.set(chapterId, urls);
|
|
||||||
touchChapterOrder(chapterId);
|
|
||||||
// Evict oldest chapters if we're over the limit, but always keep the
|
|
||||||
// immediately adjacent chapters so navigation is instant.
|
|
||||||
while (pageCache.current.size > MAX_CACHED_CHAPTERS) {
|
|
||||||
evictOldestChapter(pageCache, new Set([chapterId]));
|
|
||||||
}
|
|
||||||
fetchingRef.current.delete(chapterId);
|
|
||||||
return urls;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Load pages ──────────────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeChapter) return;
|
|
||||||
setLoading(true); setError(null); setPageGroups([]); setPageReady(false);
|
|
||||||
// Reset strip state for new chapter navigation (non-scroll transitions)
|
|
||||||
appendedRef.current = new Set();
|
|
||||||
markedReadRef.current = new Set();
|
|
||||||
|
|
||||||
const targetId = activeChapter.id;
|
|
||||||
loadingChapterRef.current = targetId;
|
|
||||||
|
|
||||||
fetchPages(targetId)
|
|
||||||
.then(async (urls) => {
|
|
||||||
// Discard result if the user has already navigated to a different chapter
|
|
||||||
if (loadingChapterRef.current !== targetId) return;
|
|
||||||
|
|
||||||
// Decode the first page before committing so no previous chapter flashes
|
|
||||||
await decodeImage(urls[0]);
|
|
||||||
|
|
||||||
if (loadingChapterRef.current !== targetId) return;
|
|
||||||
|
|
||||||
setPageUrls(urls);
|
|
||||||
setPageReady(true);
|
|
||||||
if (style === "longstrip" && autoNext) {
|
|
||||||
setStripChapters([{
|
|
||||||
chapterId: activeChapter.id,
|
|
||||||
chapterName: activeChapter.name,
|
|
||||||
urls,
|
|
||||||
startGlobalIdx: 0,
|
|
||||||
}]);
|
|
||||||
setVisibleChapterId(activeChapter.id);
|
|
||||||
} else {
|
|
||||||
setStripChapters([]);
|
|
||||||
setVisibleChapterId(null);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
|
||||||
.finally(() => {
|
|
||||||
if (loadingChapterRef.current === targetId) setLoading(false);
|
|
||||||
});
|
|
||||||
}, [activeChapter?.id]);
|
|
||||||
|
|
||||||
// ── Double-page grouping ─────────────────────────────────────────────────────
|
|
||||||
// Page 1 (cover) always solo. Wide pages (aspect > 1.2) always solo.
|
|
||||||
// Remaining portrait pages pair left-to-right: [2,3], [4,5], ...
|
|
||||||
useEffect(() => {
|
|
||||||
if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
const aspects: number[] = [];
|
|
||||||
for (const url of pageUrls) {
|
|
||||||
if (aspectCache.current.has(url)) {
|
|
||||||
aspects.push(aspectCache.current.get(url)!);
|
|
||||||
} else {
|
|
||||||
const a = await measureAspect(url);
|
|
||||||
aspectCache.current.set(url, a);
|
|
||||||
aspects.push(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cancelled) return;
|
|
||||||
const groups: number[][] = [];
|
|
||||||
groups.push([1]);
|
|
||||||
let i = 2;
|
|
||||||
while (i <= pageUrls.length) {
|
|
||||||
const a = aspects[i - 1];
|
|
||||||
if (a > 1.2) {
|
|
||||||
groups.push([i]); i++;
|
|
||||||
} else if (i === pageUrls.length) {
|
|
||||||
groups.push([i]); i++;
|
|
||||||
} else {
|
|
||||||
const nextA = aspects[i];
|
|
||||||
if (nextA !== undefined && nextA <= 1.2) {
|
|
||||||
// Book order: left page is i, right page is i+1
|
|
||||||
groups.push(rtl ? [i + 1, i] : [i, i + 1]);
|
|
||||||
i += 2;
|
|
||||||
} else {
|
|
||||||
groups.push([i]); i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setPageGroups(groups);
|
|
||||||
})();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);
|
|
||||||
|
|
||||||
// ── Preload ─────────────────────────────────────────────────────────────────
|
|
||||||
// Eagerly decode pages ahead; fire-and-forget preload for pages behind.
|
|
||||||
useEffect(() => {
|
|
||||||
const ahead = settings.preloadPages ?? 3;
|
|
||||||
for (let i = 1; i <= ahead; i++) {
|
|
||||||
const url = pageUrls[pageNumber - 1 + i];
|
|
||||||
if (url) decodeImage(url); // uses browser cache — no duplicate network request
|
|
||||||
}
|
|
||||||
// Also keep one page behind warm
|
|
||||||
const behindUrl = pageUrls[pageNumber - 2];
|
|
||||||
if (behindUrl) preloadImage(behindUrl);
|
|
||||||
}, [pageNumber, pageUrls, settings.preloadPages]);
|
|
||||||
|
|
||||||
// ── Adjacent chapters ────────────────────────────────────────────────────────
|
|
||||||
const adjacent = useMemo(() => {
|
|
||||||
if (!activeChapter || !activeChapterList.length)
|
|
||||||
return { prev: null, next: null, remaining: [] };
|
|
||||||
const idx = activeChapterList.findIndex((c) => c.id === activeChapter.id);
|
|
||||||
return {
|
|
||||||
prev: idx > 0 ? activeChapterList[idx - 1] : null,
|
|
||||||
next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
|
|
||||||
remaining: activeChapterList.slice(idx + 1),
|
|
||||||
};
|
|
||||||
}, [activeChapter, activeChapterList]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const pinned = new Set<number>();
|
|
||||||
if (activeChapter) pinned.add(activeChapter.id);
|
|
||||||
if (adjacent.next) pinned.add(adjacent.next.id);
|
|
||||||
if (adjacent.prev) pinned.add(adjacent.prev.id);
|
|
||||||
|
|
||||||
const preload = (id: number) => {
|
|
||||||
fetchPages(id)
|
|
||||||
.then((urls) => urls.slice(0, 3).forEach(preloadImage))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
if (adjacent.next) preload(adjacent.next.id);
|
|
||||||
if (adjacent.prev) preload(adjacent.prev.id);
|
|
||||||
|
|
||||||
// After preloads are kicked off, evict anything beyond MAX_CACHED_CHAPTERS
|
|
||||||
// that isn't pinned as adjacent or current.
|
|
||||||
while (pageCache.current.size > MAX_CACHED_CHAPTERS) {
|
|
||||||
const evicted = evictOldestChapter(pageCache, pinned);
|
|
||||||
if (evicted === null) break; // nothing left to evict
|
|
||||||
}
|
|
||||||
}, [adjacent.next?.id, adjacent.prev?.id]);
|
|
||||||
|
|
||||||
const lastPage = pageUrls.length;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In infinite-strip mode, the topbar shows whichever chapter the user is
|
|
||||||
* currently scrolled into rather than the "root" chapter we opened with.
|
|
||||||
*/
|
|
||||||
const displayChapter = useMemo(() => {
|
|
||||||
if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter;
|
|
||||||
return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter;
|
|
||||||
}, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In infinite-strip mode, the "last page" shown in the topbar is relative
|
|
||||||
* to the currently visible chapter chunk.
|
|
||||||
*/
|
|
||||||
const visibleChunkLastPage = useMemo(() => {
|
|
||||||
if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return lastPage;
|
|
||||||
const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
|
|
||||||
return chunk ? chunk.urls.length : lastPage;
|
|
||||||
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]);
|
|
||||||
|
|
||||||
/** Page number within the currently visible chapter chunk (for topbar) */
|
|
||||||
const visibleChunkPage = useMemo(() => {
|
|
||||||
if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return pageNumber;
|
|
||||||
const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
|
|
||||||
if (!chunk) return pageNumber;
|
|
||||||
return Math.max(1, pageNumber - chunk.startGlobalIdx);
|
|
||||||
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]);
|
|
||||||
|
|
||||||
// ── Auto-mark read + history ─────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeChapter || !lastPage) return;
|
|
||||||
if (activeManga) {
|
|
||||||
addHistory({
|
|
||||||
mangaId: activeManga.id, mangaTitle: activeManga.title,
|
|
||||||
thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id,
|
|
||||||
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (settings.autoMarkRead && pageNumber === lastPage) {
|
|
||||||
if (!markedReadRef.current.has(activeChapter.id)) {
|
|
||||||
markedReadRef.current.add(activeChapter.id);
|
|
||||||
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead]);
|
|
||||||
|
|
||||||
// ── Navigation ──────────────────────────────────────────────────────────────
|
|
||||||
const advanceGroup = useCallback((forward: boolean) => {
|
|
||||||
if (!pageGroups.length) return;
|
|
||||||
const gi = pageGroups.findIndex((g) => g.includes(pageNumber));
|
|
||||||
if (forward) {
|
|
||||||
if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]);
|
|
||||||
else if (adjacent.next) { setPageNumber(1); openReader(adjacent.next, activeChapterList); }
|
|
||||||
else closeReader();
|
|
||||||
} else {
|
|
||||||
if (gi > 0) setPageNumber(pageGroups[gi - 1][0]);
|
|
||||||
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
|
||||||
}
|
|
||||||
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
|
|
||||||
|
|
||||||
const goForward = useCallback(() => {
|
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
|
||||||
if (pageNumber < lastPage) {
|
|
||||||
const nextUrl = pageUrls[pageNumber]; // pageNumber is 1-based, so index is pageNumber
|
|
||||||
if (nextUrl) {
|
|
||||||
decodeImage(nextUrl).then(() => setPageNumber(pageNumber + 1));
|
|
||||||
} else {
|
|
||||||
setPageNumber(pageNumber + 1);
|
|
||||||
}
|
|
||||||
} else if (adjacent.next) {
|
|
||||||
setPageNumber(1);
|
|
||||||
openReader(adjacent.next, activeChapterList);
|
|
||||||
} else {
|
|
||||||
closeReader();
|
|
||||||
}
|
|
||||||
}, [pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
|
||||||
if (pageNumber > 1) {
|
|
||||||
const prevUrl = pageUrls[pageNumber - 2]; // 0-based index of previous page
|
|
||||||
if (prevUrl) {
|
|
||||||
decodeImage(prevUrl).then(() => setPageNumber(pageNumber - 1));
|
|
||||||
} else {
|
|
||||||
setPageNumber(pageNumber - 1);
|
|
||||||
}
|
|
||||||
} else if (adjacent.prev) {
|
|
||||||
openReader(adjacent.prev, activeChapterList);
|
|
||||||
}
|
|
||||||
}, [pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
|
||||||
|
|
||||||
const goNext = rtl ? goBack : goForward;
|
|
||||||
const goPrev = rtl ? goForward : goBack;
|
|
||||||
|
|
||||||
function cycleStyle() {
|
|
||||||
const cycle = ["single", "longstrip"] as const;
|
|
||||||
const cur = style === "double" ? "single" : style;
|
|
||||||
const next = cycle[(cycle.indexOf(cur as any) + 1) % cycle.length];
|
|
||||||
updateSettings({ pageStyle: next });
|
|
||||||
}
|
|
||||||
|
|
||||||
function cycleFit() {
|
|
||||||
const cycle: FitMode[] = ["width", "height", "screen", "original"];
|
|
||||||
updateSettings({ fitMode: cycle[(cycle.indexOf(fit) + 1) % cycle.length] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ctrl+scroll → zoom ───────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
|
||||||
if (!e.ctrlKey) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const delta = e.deltaY < 0 ? 50 : -50;
|
|
||||||
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + delta)) });
|
|
||||||
};
|
|
||||||
window.addEventListener("wheel", onWheel, { passive: false });
|
|
||||||
return () => window.removeEventListener("wheel", onWheel);
|
|
||||||
}, [maxW]);
|
|
||||||
|
|
||||||
// ── Keybinds ─────────────────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
|
||||||
|
|
||||||
// Escape: close overlays in priority order, then exit reader
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (zoomOpen) { setZoomOpen(false); return; }
|
|
||||||
if (dlOpen) { setDlOpen(false); return; }
|
|
||||||
closeReader();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl += / Ctrl + / Ctrl - / Ctrl 0 → zoom
|
|
||||||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) {
|
|
||||||
e.preventDefault();
|
|
||||||
updateSettings({ maxPageWidth: Math.min(2400, maxW + 100) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.ctrlKey && e.key === "-") {
|
|
||||||
e.preventDefault();
|
|
||||||
updateSettings({ maxPageWidth: Math.max(200, maxW - 100) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.ctrlKey && e.key === "0") {
|
|
||||||
e.preventDefault();
|
|
||||||
updateSettings({ maxPageWidth: 900 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
|
||||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
|
||||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
|
||||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
|
|
||||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
|
|
||||||
else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
|
|
||||||
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
|
|
||||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
|
||||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
|
|
||||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
|
||||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); }
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen, maxW]);
|
|
||||||
|
|
||||||
// ── Longstrip scroll tracker ─────────────────────────────────────────────────
|
|
||||||
// Tracks current page number. In autoNext mode, appends the next chapter's
|
|
||||||
// pages directly into the strip (no re-render / scroll reset) so the flow
|
|
||||||
// is one seamless ribbon of images.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el || style !== "longstrip") return;
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
cancelAnimationFrame(rafRef.current);
|
|
||||||
rafRef.current = requestAnimationFrame(() => {
|
|
||||||
if (!el) return;
|
|
||||||
const imgs = Array.from(el.querySelectorAll("img[data-page]")) as HTMLElement[];
|
|
||||||
|
|
||||||
// Find the image whose center is closest to the viewport center
|
|
||||||
const viewMid = el.scrollTop + el.clientHeight * 0.5;
|
|
||||||
let closest = 0;
|
|
||||||
let closestDist = Infinity;
|
|
||||||
for (let i = 0; i < imgs.length; i++) {
|
|
||||||
const imgMid = imgs[i].offsetTop + imgs[i].offsetHeight * 0.5;
|
|
||||||
const dist = Math.abs(imgMid - viewMid);
|
|
||||||
if (dist < closestDist) { closestDist = dist; closest = i; }
|
|
||||||
}
|
|
||||||
const n = closest + 1;
|
|
||||||
if (n !== pageNumRef.current) setPageNumber(n);
|
|
||||||
|
|
||||||
// ── Infinite append ──────────────────────────────────────────────────
|
|
||||||
if (!autoNext) {
|
|
||||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
|
||||||
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const strip = stripChaptersRef.current;
|
|
||||||
|
|
||||||
// Silently update visibleChapterId as we scroll into each chunk
|
|
||||||
for (const chunk of strip) {
|
|
||||||
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
|
|
||||||
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
|
|
||||||
if (chunk.chapterId !== visibleChapterId) {
|
|
||||||
setVisibleChapterId(chunk.chapterId);
|
|
||||||
if (settings.autoMarkRead) {
|
|
||||||
const prevChunk = strip[strip.indexOf(chunk) - 1];
|
|
||||||
if (prevChunk) {
|
|
||||||
if (!markedReadRef.current.has(prevChunk.chapterId)) {
|
|
||||||
markedReadRef.current.add(prevChunk.chapterId);
|
|
||||||
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append next chapter when within 300px of the bottom
|
|
||||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
|
|
||||||
if (!nearBottom) return;
|
|
||||||
|
|
||||||
const lastChunk = strip[strip.length - 1];
|
|
||||||
if (!lastChunk) return;
|
|
||||||
|
|
||||||
const lastChunkIdx = activeChapterList.findIndex((c) => c.id === lastChunk.chapterId);
|
|
||||||
if (lastChunkIdx < 0 || lastChunkIdx >= activeChapterList.length - 1) return;
|
|
||||||
|
|
||||||
const nextChEntry = activeChapterList[lastChunkIdx + 1];
|
|
||||||
if (!nextChEntry || appendedRef.current.has(nextChEntry.id)) return;
|
|
||||||
|
|
||||||
appendedRef.current.add(nextChEntry.id);
|
|
||||||
|
|
||||||
fetchPages(nextChEntry.id).then((urls) => {
|
|
||||||
setStripChapters((prev) => {
|
|
||||||
const lastInPrev = prev[prev.length - 1];
|
|
||||||
const newStart = lastInPrev ? lastInPrev.startGlobalIdx + lastInPrev.urls.length : 0;
|
|
||||||
const next = [
|
|
||||||
...prev,
|
|
||||||
{ chapterId: nextChEntry.id, chapterName: nextChEntry.name, urls, startGlobalIdx: newStart },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MAX_STRIP_CHAPTERS = 3;
|
|
||||||
if (next.length > MAX_STRIP_CHAPTERS) {
|
|
||||||
const toRemove = next.length - MAX_STRIP_CHAPTERS;
|
|
||||||
// Snapshot scroll position now, inside the state updater, before React
|
|
||||||
// removes the nodes. useLayoutEffect will restore it after the DOM mutation.
|
|
||||||
scrollAnchorRef.current = { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight };
|
|
||||||
return next.slice(toRemove);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}).catch(console.error);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
el.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
el.removeEventListener("scroll", onScroll);
|
|
||||||
cancelAnimationFrame(rafRef.current);
|
|
||||||
};
|
|
||||||
}, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages, visibleChapterId]);
|
|
||||||
|
|
||||||
// Reset scroll position when switching chapters in non-longstrip modes
|
|
||||||
useEffect(() => {
|
|
||||||
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
|
||||||
}, [pageNumber, style]);
|
|
||||||
|
|
||||||
// When switching to longstrip, reset scroll to top and rebuild strip from current chapter
|
|
||||||
useEffect(() => {
|
|
||||||
if (style === "longstrip" && containerRef.current) {
|
|
||||||
containerRef.current.scrollTop = 0;
|
|
||||||
if (activeChapter && pageUrls.length > 0) {
|
|
||||||
appendedRef.current = new Set();
|
|
||||||
if (autoNext) {
|
|
||||||
setStripChapters([{
|
|
||||||
chapterId: activeChapter.id,
|
|
||||||
chapterName: activeChapter.name,
|
|
||||||
urls: pageUrls,
|
|
||||||
startGlobalIdx: 0,
|
|
||||||
}]);
|
|
||||||
setVisibleChapterId(activeChapter.id);
|
|
||||||
} else {
|
|
||||||
// Plain longstrip — no multi-chapter strip
|
|
||||||
setStripChapters([]);
|
|
||||||
setVisibleChapterId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (style !== "longstrip") {
|
|
||||||
setStripChapters([]);
|
|
||||||
setVisibleChapterId(null);
|
|
||||||
}
|
|
||||||
}, [activeChapter?.id, style, autoNext]);
|
|
||||||
|
|
||||||
function handleTap(e: React.MouseEvent) {
|
|
||||||
if (style === "longstrip") return;
|
|
||||||
const x = e.clientX / window.innerWidth;
|
|
||||||
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
|
|
||||||
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CSS vars ─────────────────────────────────────────────────────────────────
|
|
||||||
const cssVars = { "--max-page-width": `${maxW}px` } as React.CSSProperties;
|
|
||||||
|
|
||||||
const imgCls = [
|
|
||||||
s.img,
|
|
||||||
fit === "width" && s.fitWidth,
|
|
||||||
fit === "height" && s.fitHeight,
|
|
||||||
fit === "screen" && s.fitScreen,
|
|
||||||
fit === "original" && s.fitOriginal,
|
|
||||||
settings.optimizeContrast && s.optimizeContrast,
|
|
||||||
].filter(Boolean).join(" ");
|
|
||||||
|
|
||||||
// ── Icons ────────────────────────────────────────────────────────────────────
|
|
||||||
const fitIcon =
|
|
||||||
fit === "width" ? <ArrowsLeftRight size={14} weight="light" /> :
|
|
||||||
fit === "height" ? <ArrowsVertical size={14} weight="light" /> :
|
|
||||||
fit === "screen" ? <ArrowsIn size={14} weight="light" /> :
|
|
||||||
<ArrowsOut size={14} weight="light" />;
|
|
||||||
|
|
||||||
const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit];
|
|
||||||
|
|
||||||
const styleIcon = style === "single" ? <Square size={14} weight="light" /> : <Rows size={14} weight="light" />;
|
|
||||||
|
|
||||||
if (loading) return (
|
|
||||||
<div className={s.center}>
|
|
||||||
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) return (
|
|
||||||
<div className={s.center}><p className={s.errorMsg}>{error}</p></div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={s.root}
|
|
||||||
onMouseMove={(e) => {
|
|
||||||
const fromTop = e.clientY;
|
|
||||||
const fromBottom = window.innerHeight - e.clientY;
|
|
||||||
if (fromTop < 60 || fromBottom < 60) showUi();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* ── Topbar ── */}
|
|
||||||
<div
|
|
||||||
ref={uiRef}
|
|
||||||
className={[s.topbar, uiVisible ? "" : s.uiHidden].join(" ")}
|
|
||||||
>
|
|
||||||
<button className={s.iconBtn} onClick={closeReader} title="Close reader">
|
|
||||||
<X size={15} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={s.iconBtn}
|
|
||||||
onClick={() => adjacent.prev && openReader(adjacent.prev, activeChapterList)}
|
|
||||||
disabled={!adjacent.prev}
|
|
||||||
title="Previous chapter"
|
|
||||||
>
|
|
||||||
<CaretLeft size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<span className={s.chLabel}>
|
|
||||||
<span className={s.chTitle}>{activeManga?.title}</span>
|
|
||||||
<span className={s.chSep}>/</span>
|
|
||||||
<span>{displayChapter?.name}</span>
|
|
||||||
</span>
|
|
||||||
<span className={s.pageLabel}>{visibleChunkPage} / {visibleChunkLastPage || "…"}</span>
|
|
||||||
<button
|
|
||||||
className={s.iconBtn}
|
|
||||||
onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
|
|
||||||
disabled={!adjacent.next}
|
|
||||||
title="Next chapter"
|
|
||||||
>
|
|
||||||
<CaretRight size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className={s.topSep} />
|
|
||||||
|
|
||||||
{/* Fit mode */}
|
|
||||||
<button className={s.modeBtn} onClick={cycleFit} title={`Fit mode: ${fitLabel}\nCtrl+scroll to zoom`}>
|
|
||||||
{fitIcon}
|
|
||||||
<span className={s.modeBtnLabel}>{fitLabel}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Zoom */}
|
|
||||||
<div className={s.zoomWrap}>
|
|
||||||
<button
|
|
||||||
className={s.zoomBtn}
|
|
||||||
onClick={() => setZoomOpen((o) => !o)}
|
|
||||||
title="Zoom (click for slider, Ctrl+scroll)"
|
|
||||||
>
|
|
||||||
{Math.round((maxW / 900) * 100)}%
|
|
||||||
</button>
|
|
||||||
{zoomOpen && (
|
|
||||||
<ZoomPopover
|
|
||||||
value={maxW}
|
|
||||||
onChange={(v) => updateSettings({ maxPageWidth: v })}
|
|
||||||
onReset={() => updateSettings({ maxPageWidth: 900 })}
|
|
||||||
onClose={() => setZoomOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RTL */}
|
|
||||||
<button
|
|
||||||
className={[s.modeBtn, rtl ? s.modeBtnActive : ""].join(" ")}
|
|
||||||
onClick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}
|
|
||||||
title={`Direction: ${rtl ? "RTL" : "LTR"}`}
|
|
||||||
>
|
|
||||||
<ArrowsLeftRight size={14} weight="light" />
|
|
||||||
<span className={s.modeBtnLabel}>{rtl ? "RTL" : "LTR"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Page style */}
|
|
||||||
<button className={s.modeBtn} onClick={cycleStyle} title={`Layout: ${style}`}>
|
|
||||||
{styleIcon}
|
|
||||||
<span className={s.modeBtnLabel}>{style}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Page gap toggle */}
|
|
||||||
{style !== "single" && (
|
|
||||||
<button
|
|
||||||
className={[s.modeBtn, settings.pageGap ? s.modeBtnActive : ""].join(" ")}
|
|
||||||
onClick={() => updateSettings({ pageGap: !settings.pageGap })}
|
|
||||||
title="Toggle page gap"
|
|
||||||
>
|
|
||||||
<span className={s.modeBtnLabel}>Gap</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Auto-next chapter */}
|
|
||||||
{style === "longstrip" && (
|
|
||||||
<button
|
|
||||||
className={[s.modeBtn, autoNext ? s.modeBtnActive : ""].join(" ")}
|
|
||||||
onClick={() => updateSettings({ autoNextChapter: !autoNext })}
|
|
||||||
title="Auto-advance to next chapter"
|
|
||||||
>
|
|
||||||
<span className={s.modeBtnLabel}>Auto</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Download */}
|
|
||||||
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
|
|
||||||
<Download size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Viewer ── */}
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={[s.viewer, style === "longstrip" ? s.viewerStrip : ""].join(" ")}
|
|
||||||
style={cssVars}
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={handleTap}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === " " && style === "longstrip") {
|
|
||||||
e.preventDefault();
|
|
||||||
containerRef.current?.scrollBy({ top: containerRef.current.clientHeight * 0.85, behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{style === "longstrip" ? (
|
|
||||||
<>
|
|
||||||
{(autoNext && stripChapters.length > 0 ? stripChapters : [{
|
|
||||||
chapterId: activeChapter?.id ?? 0,
|
|
||||||
chapterName: activeChapter?.name ?? "",
|
|
||||||
urls: pageUrls,
|
|
||||||
startGlobalIdx: 0,
|
|
||||||
}]).map((chunk) =>
|
|
||||||
chunk.urls.map((url, i) => {
|
|
||||||
const globalIdx = chunk.startGlobalIdx + i;
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
key={`${chunk.chapterId}-${i}`}
|
|
||||||
src={url}
|
|
||||||
alt={`${chunk.chapterName} – Page ${i + 1}`}
|
|
||||||
data-page={globalIdx + 1}
|
|
||||||
data-chapter={chunk.chapterId}
|
|
||||||
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
|
|
||||||
loading={globalIdx < 3 ? "eager" : "lazy"}
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
pageReady && (
|
|
||||||
<img
|
|
||||||
key={pageNumber}
|
|
||||||
src={pageUrls[pageNumber - 1]}
|
|
||||||
alt={`Page ${pageNumber}`}
|
|
||||||
className={imgCls}
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Bottom nav ── */}
|
|
||||||
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
|
||||||
<button className={s.navBtn} onClick={goPrev} disabled={pageNumber === 1 && !adjacent.prev}>
|
|
||||||
<ArrowLeft size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button className={s.navBtn} onClick={goNext} disabled={pageNumber === lastPage && !adjacent.next}>
|
|
||||||
<ArrowRight size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dlOpen && activeChapter && (
|
|
||||||
<DownloadModal
|
|
||||||
chapter={activeChapter}
|
|
||||||
remaining={adjacent.remaining}
|
|
||||||
onClose={() => setDlOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex; flex-direction: column; height: 100%;
|
|
||||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-4);
|
|
||||||
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.searchBar {
|
|
||||||
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-lg); padding: 0 var(--sp-3) 0 var(--sp-2);
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
|
||||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
.searchInput {
|
|
||||||
flex: 1; background: none; border: none; outline: none;
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); padding: 8px 0;
|
|
||||||
}
|
|
||||||
.searchInput::placeholder { color: var(--text-faint); }
|
|
||||||
.searchBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 12px; border-radius: var(--radius-md); flex-shrink: 0;
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); cursor: pointer;
|
|
||||||
transition: filter var(--t-base); display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.langBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
padding: var(--sp-2) var(--sp-6);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.langBtn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.langBtn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.langBtnActive {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.langBtnActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.sourceCount {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-6); }
|
|
||||||
|
|
||||||
.sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); }
|
|
||||||
|
|
||||||
.sourceHeader {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.sourceIcon { width: 18px; height: 18px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
|
||||||
.sourceName { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.resultCount {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide); margin-left: auto;
|
|
||||||
}
|
|
||||||
.sourceError { font-size: var(--text-xs); color: var(--color-error); padding: 0 var(--sp-1); }
|
|
||||||
|
|
||||||
.sourceRow {
|
|
||||||
display: flex; gap: var(--sp-3); overflow-x: auto;
|
|
||||||
padding-bottom: var(--sp-2);
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
flex-shrink: 0; width: 110px; background: none; border: none; padding: 0;
|
|
||||||
cursor: pointer; text-align: left;
|
|
||||||
}
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
|
||||||
.coverWrap {
|
|
||||||
position: relative; aspect-ratio: 2/3; border-radius: var(--radius-md);
|
|
||||||
overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
|
|
||||||
.inLibBadge {
|
|
||||||
position: absolute; bottom: var(--sp-1); left: var(--sp-1);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.cardTitle {
|
|
||||||
margin-top: var(--sp-1); font-size: var(--text-xs); color: var(--text-muted);
|
|
||||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skCard { flex-shrink: 0; width: 110px; }
|
|
||||||
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; }
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
flex: 1; display: flex; flex-direction: column;
|
|
||||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.emptyIcon { color: var(--text-faint); }
|
|
||||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
|
||||||
import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Manga, Source } from "../../lib/types";
|
|
||||||
import s from "./Search.module.css";
|
|
||||||
|
|
||||||
interface SourceResult {
|
|
||||||
source: Source;
|
|
||||||
mangas: Manga[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONCURRENCY = 3;
|
|
||||||
|
|
||||||
async function runConcurrent<T>(
|
|
||||||
items: T[],
|
|
||||||
fn: (item: T) => Promise<void>
|
|
||||||
): Promise<void> {
|
|
||||||
let i = 0;
|
|
||||||
async function worker() {
|
|
||||||
while (i < items.length) {
|
|
||||||
const item = items[i++];
|
|
||||||
await fn(item).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Search() {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [submitted, setSubmitted] = useState("");
|
|
||||||
const [results, setResults] = useState<SourceResult[]>([]);
|
|
||||||
const [allSources, setAllSources] = useState<Source[]>([]);
|
|
||||||
const [loadingSources, setLoadingSources] = useState(false);
|
|
||||||
const [activeLang, setActiveLang] = useState<string>("preferred");
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const setActiveManga = useStore((st) => st.setActiveManga);
|
|
||||||
const setNavPage = useStore((st) => st.setNavPage);
|
|
||||||
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoadingSources(true);
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then((d) => setAllSources(d.sources.nodes.filter((s) => s.id !== "0")))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoadingSources(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const langs = ["preferred", ...Array.from(new Set(allSources.map((s) => s.lang))).sort(), "all"];
|
|
||||||
|
|
||||||
const visibleSources = allSources.filter((src) => {
|
|
||||||
if (activeLang === "all") return true;
|
|
||||||
if (activeLang === "preferred") return src.lang === preferredLang;
|
|
||||||
return src.lang === activeLang;
|
|
||||||
});
|
|
||||||
|
|
||||||
const runSearch = useCallback(async () => {
|
|
||||||
const q = query.trim();
|
|
||||||
if (!q || !visibleSources.length) return;
|
|
||||||
setSubmitted(q);
|
|
||||||
|
|
||||||
setResults(visibleSources.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
|
|
||||||
|
|
||||||
await runConcurrent(visibleSources, async (src) => {
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "SEARCH", page: 1, query: q,
|
|
||||||
});
|
|
||||||
setResults((prev) => prev.map((r) =>
|
|
||||||
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
|
|
||||||
));
|
|
||||||
} catch (e: any) {
|
|
||||||
setResults((prev) => prev.map((r) =>
|
|
||||||
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [query, visibleSources]);
|
|
||||||
|
|
||||||
function openManga(m: Manga) {
|
|
||||||
setActiveManga(m);
|
|
||||||
setNavPage("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasResults = results.some((r) => r.mangas.length > 0);
|
|
||||||
const allDone = results.every((r) => !r.loading);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<h1 className={s.heading}>Search</h1>
|
|
||||||
<div className={s.searchBar}>
|
|
||||||
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
className={s.searchInput}
|
|
||||||
placeholder="Search across sources…"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && runSearch()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className={s.searchBtn}
|
|
||||||
onClick={runSearch}
|
|
||||||
disabled={!query.trim() || loadingSources}
|
|
||||||
>
|
|
||||||
{loadingSources
|
|
||||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
|
||||||
: "Search"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.langBar}>
|
|
||||||
{langs.map((l) => (
|
|
||||||
<button
|
|
||||||
key={l}
|
|
||||||
onClick={() => setActiveLang(l)}
|
|
||||||
className={[s.langBtn, activeLang === l ? s.langBtnActive : ""].join(" ").trim()}
|
|
||||||
>
|
|
||||||
{l === "preferred" ? `${preferredLang.toUpperCase()} (default)` : l === "all" ? "All" : l.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{visibleSources.length > 0 && (
|
|
||||||
<span className={s.sourceCount}>{visibleSources.length} sources</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!submitted && (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
|
||||||
<p className={s.emptyText}>Search across sources</p>
|
|
||||||
<p className={s.emptyHint}>
|
|
||||||
Searching {visibleSources.length} {activeLang === "preferred" ? `${preferredLang.toUpperCase()}` : activeLang === "all" ? "" : activeLang.toUpperCase()} source{visibleSources.length !== 1 ? "s" : ""}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{submitted && (
|
|
||||||
<div className={s.results}>
|
|
||||||
{results.length === 0 && (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{results
|
|
||||||
.filter((r) => r.mangas.length > 0 || r.loading || r.error)
|
|
||||||
.map(({ source, mangas, loading, error }) => (
|
|
||||||
<div key={source.id} className={s.sourceSection}>
|
|
||||||
<div className={s.sourceHeader}>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(source.iconUrl)}
|
|
||||||
alt={source.displayName}
|
|
||||||
className={s.sourceIcon}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
||||||
/>
|
|
||||||
<span className={s.sourceName}>{source.displayName}</span>
|
|
||||||
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
|
||||||
{!loading && mangas.length > 0 && (
|
|
||||||
<span className={s.resultCount}>{mangas.length} results</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className={s.sourceError}>{error}</p>
|
|
||||||
) : loading ? (
|
|
||||||
<div className={s.sourceRow}>
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.skCard}>
|
|
||||||
<div className={["skeleton", s.skCover].join(" ")} />
|
|
||||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : mangas.length > 0 ? (
|
|
||||||
<div className={s.sourceRow}>
|
|
||||||
{mangas.slice(0, 8).map((m) => (
|
|
||||||
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
|
||||||
{m.inLibrary && <span className={s.inLibBadge}>In Library</span>}
|
|
||||||
</div>
|
|
||||||
<p className={s.cardTitle}>{m.title}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{allDone && !hasResults && submitted && (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<p className={s.emptyText}>No results for "{submitted}"</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,876 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sidebar ── */
|
|
||||||
.sidebar {
|
|
||||||
width: 200px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: var(--sp-5);
|
|
||||||
border-right: 1px solid var(--border-dim);
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
background: var(--bg-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.back {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.back:hover { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.coverWrap {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
|
|
||||||
.metaSkeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.skLine { border-radius: var(--radius-sm); }
|
|
||||||
|
|
||||||
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.byline {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge {
|
|
||||||
display: inline-block;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusOngoing {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusEnded {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-faint);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
|
||||||
|
|
||||||
.genre {
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 1px 6px;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: var(--leading-base);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 8;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Progress ── */
|
|
||||||
.progressSection {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressPct {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressTrack {
|
|
||||||
height: 3px;
|
|
||||||
background: var(--border-base);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressFill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Actions ── */
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.libraryBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.libraryBtn:hover { border-color: var(--accent); color: var(--accent-fg); }
|
|
||||||
.libraryBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.libraryBtnActive {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.externalLink {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.externalLink:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
/* ── Start/Continue reading button ── */
|
|
||||||
.readBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-dim);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), border-color var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.readBtn:hover { background: var(--accent-muted); border-color: var(--accent-bright); }
|
|
||||||
|
|
||||||
.chapterCount {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Chapter list ── */
|
|
||||||
.listWrap {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sortBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.sortBtn:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paginationBottom {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.pageBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
|
|
||||||
.pageBtn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.pageNum {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
min-width: 40px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--sp-2) var(--sp-4);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowSkeleton {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: 12px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
margin-bottom: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 10px var(--sp-3);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.row:hover { background: var(--bg-raised); }
|
|
||||||
.rowRead .chName { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.chLeft {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chName {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
transition: color var(--t-fast);
|
|
||||||
}
|
|
||||||
.row:hover .chName { color: var(--text-primary); }
|
|
||||||
|
|
||||||
.chMeta { display: flex; align-items: center; gap: var(--sp-3); }
|
|
||||||
|
|
||||||
.chMetaItem {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chRight {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmarkIcon { color: var(--accent); }
|
|
||||||
.readIcon { color: var(--text-faint); }
|
|
||||||
.downloadedIcon { color: var(--accent-fg); }
|
|
||||||
.enqueuingIcon { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.dlBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.dlBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
/* ── Download section ── */
|
|
||||||
.downloadSection {
|
|
||||||
position: relative; margin-top: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloadToggle {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
width: 100%; padding: 7px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: none; color: var(--text-muted);
|
|
||||||
font-size: var(--text-sm); cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.downloadToggle:hover { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.downloadMenu {
|
|
||||||
margin-top: var(--sp-1);
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-lg); padding: var(--sp-1);
|
|
||||||
display: flex; flex-direction: column; gap: 1px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlItem {
|
|
||||||
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
|
|
||||||
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm); color: var(--text-secondary);
|
|
||||||
background: none; border: none; cursor: pointer; text-align: left;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
}
|
|
||||||
.dlItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
|
|
||||||
.dlItemSub { font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
/* ── Details section ── */
|
|
||||||
.detailsSection {
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
padding-top: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailsToggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: 4px var(--sp-1);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.detailsToggle:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.caretClosed { transition: transform var(--t-base); }
|
|
||||||
.caretOpen { transform: rotate(180deg); transition: transform var(--t-base); }
|
|
||||||
|
|
||||||
.detailsBody {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailRow {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailKey {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailVal {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-align: right;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailMono {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.migrateBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px var(--sp-2);
|
|
||||||
margin-top: var(--sp-1);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.migrateBtn:hover {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
/* ── List header right controls ── */
|
|
||||||
.listHeaderRight {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Download dropdown (in list header) ── */
|
|
||||||
.dlWrap { position: relative; }
|
|
||||||
|
|
||||||
.dlToggleBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim); color: var(--text-muted);
|
|
||||||
background: none; cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.dlToggleBtn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.dlDropdown {
|
|
||||||
position: absolute; top: calc(100% + 4px); right: 0;
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-lg); padding: var(--sp-1);
|
|
||||||
display: flex; flex-direction: column; gap: 1px;
|
|
||||||
min-width: 180px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
|
||||||
animation: scaleIn 0.1s ease both; transform-origin: top right;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Jump to chapter (in list header) ── */
|
|
||||||
.jumpWrap { position: relative; }
|
|
||||||
|
|
||||||
.jumpToggle {
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
|
||||||
background: none; color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
cursor: pointer; white-space: nowrap;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.jumpToggle:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.jumpRow { display: flex; align-items: center; gap: 4px; }
|
|
||||||
|
|
||||||
.jumpInput {
|
|
||||||
width: 72px; padding: 4px 8px;
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-focus);
|
|
||||||
border-radius: var(--radius-sm); color: var(--text-secondary);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jumpCancel {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 22px; height: 22px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); font-size: 10px; background: none;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.jumpCancel:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
/* ── View mode toggle ── */
|
|
||||||
.viewToggleBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 26px; height: 26px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.viewToggleBtn:hover { color: var(--text-muted); background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.viewToggleActive { color: var(--accent-fg) !important; background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
|
|
||||||
|
|
||||||
/* ── Chapter grid ── */
|
|
||||||
.grid {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
|
|
||||||
gap: 5px;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gridCell {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color var(--t-fast), background var(--t-fast), transform var(--t-fast);
|
|
||||||
}
|
|
||||||
.gridCell:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
transform: scale(1.04);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unread — subtle, inviting */
|
|
||||||
.gridCellNum {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Read — dimmed, clearly consumed */
|
|
||||||
.gridCellRead {
|
|
||||||
background: var(--bg-base);
|
|
||||||
border-color: var(--border-dim);
|
|
||||||
}
|
|
||||||
.gridCellRead .gridCellNum {
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
.gridCellRead::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute; inset: 0;
|
|
||||||
background: linear-gradient(135deg, transparent 60%, rgba(var(--accent-rgb, 100 130 255) / 0.08) 100%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* In-progress — accent highlight on bottom edge */
|
|
||||||
.gridCellInProgress {
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
.gridCellInProgress .gridCellNum {
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.gridCellInProgress::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute; bottom: 0; left: 0; right: 0;
|
|
||||||
height: 3px;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Read indicator dot (top-right corner) */
|
|
||||||
.gridCellDot {
|
|
||||||
position: absolute; top: 3px; right: 3px;
|
|
||||||
width: 4px; height: 4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark indicator dot */
|
|
||||||
.gridCellBookmarked { border-color: var(--accent-dim); }
|
|
||||||
.gridCellBookmarkDot {
|
|
||||||
position: absolute; top: 3px; left: 3px;
|
|
||||||
width: 4px; height: 4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Spinner overlay for enqueueing */
|
|
||||||
.gridCellSpinner {
|
|
||||||
position: absolute; inset: 0;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
background: rgba(0,0,0,0.3);
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeleton for grid loading state */
|
|
||||||
.gridCellSkeleton {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
padding: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Folder picker (icon button in list header) ──────────────────────── */
|
|
||||||
.folderPickerWrap {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Matches dlToggleBtn / viewToggleBtn style */
|
|
||||||
.folderPickerBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.folderPickerBtn:hover {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
/* Active state when manga is assigned to at least one folder */
|
|
||||||
.folderPickerBtnActive {
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
}
|
|
||||||
.folderPickerBtnActive:hover {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderPickerMenu {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 4px);
|
|
||||||
right: 0;
|
|
||||||
min-width: 180px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--sp-1);
|
|
||||||
z-index: 200;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
||||||
animation: scaleIn 0.1s ease both;
|
|
||||||
transform-origin: top right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderPickerEmpty {
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderPickerItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px var(--sp-3);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
}
|
|
||||||
.folderPickerItem:hover { background: var(--bg-overlay); }
|
|
||||||
.folderPickerItemActive { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.folderPickerItemCheck {
|
|
||||||
width: 12px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderPickerDivider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border-dim);
|
|
||||||
margin: var(--sp-1) var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderPickerCreate {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
padding: 4px var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderPickerInput {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
outline: none;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.folderPickerInput:focus { border-color: var(--border-focus); }
|
|
||||||
|
|
||||||
.folderPickerConfirm {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.folderPickerConfirm:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.folderPickerCancel {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.folderPickerCancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
|
||||||
|
|
||||||
.folderPickerNewBtn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px var(--sp-3);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: color var(--t-fast), background var(--t-fast);
|
|
||||||
}
|
|
||||||
.folderPickerNewBtn:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
|
||||||
|
|
||||||
/* ── Delete all downloads button (in details section) ─────────────────── */
|
|
||||||
.deleteAllBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
width: 100%;
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
padding: 7px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-error);
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-error);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: background var(--t-base);
|
|
||||||
}
|
|
||||||
.deleteAllBtn:hover:not(:disabled) { background: var(--color-error-bg); }
|
|
||||||
.deleteAllBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
/* ── Danger item in dl dropdown ─────────────────────────────────────── */
|
|
||||||
.dlItemDanger {
|
|
||||||
color: var(--color-error) !important;
|
|
||||||
}
|
|
||||||
.dlItemDanger:hover:not(:disabled) {
|
|
||||||
background: var(--color-error-bg) !important;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,919 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from "svelte";
|
||||||
|
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||||
|
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga } from "../../store/state.svelte";
|
||||||
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
import MigrateModal from "./MigrateModal.svelte";
|
||||||
|
import TrackingPanel from "../shared/TrackingPanel.svelte";
|
||||||
|
|
||||||
|
const CHAPTERS_PER_PAGE = 25;
|
||||||
|
const MANGA_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const CHAPTER_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
|
||||||
|
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
|
||||||
|
|
||||||
|
let manga: Manga | null = $state(null);
|
||||||
|
let chapters: Chapter[] = $state([]);
|
||||||
|
let loadingManga: boolean = $state(false);
|
||||||
|
let loadingChapters: boolean = $state(true);
|
||||||
|
let enqueueing: Set<number> = $state(new Set());
|
||||||
|
let dlOpen: boolean = $state(false);
|
||||||
|
let detailsOpen: boolean = $state(false);
|
||||||
|
let togglingLibrary: boolean = $state(false);
|
||||||
|
let chapterPage: number = $state(1);
|
||||||
|
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
|
||||||
|
let jumpOpen: boolean = $state(false);
|
||||||
|
let jumpInput: string = $state("");
|
||||||
|
let viewMode: "list" | "grid" = $state("list");
|
||||||
|
let deletingAll: boolean = $state(false);
|
||||||
|
let refreshing: boolean = $state(false);
|
||||||
|
let genresExpanded: boolean = $state(false);
|
||||||
|
let folderPickerOpen: boolean = $state(false);
|
||||||
|
let folderCreating: boolean = $state(false);
|
||||||
|
let folderNewName: string = $state("");
|
||||||
|
let rangeFrom: string = $state("");
|
||||||
|
let rangeTo: string = $state("");
|
||||||
|
let showRange: boolean = $state(false);
|
||||||
|
let migrateOpen: boolean = $state(false);
|
||||||
|
let dlDropRef: HTMLDivElement | undefined = $state();
|
||||||
|
let folderPickerRef: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
// Series link state
|
||||||
|
let linkPickerOpen: boolean = $state(false);
|
||||||
|
let linkSearch: string = $state("");
|
||||||
|
let allMangaForLink: Manga[] = $state([]);
|
||||||
|
let loadingLinkList: boolean = $state(false);
|
||||||
|
|
||||||
|
// Tracking modal
|
||||||
|
let trackingOpen: boolean = $state(false);
|
||||||
|
|
||||||
|
let mangaAbort: AbortController | null = null;
|
||||||
|
let chapterAbort: AbortController | null = null;
|
||||||
|
let loadingFor: number | null = null;
|
||||||
|
|
||||||
|
function formatDate(ts: string | null | undefined): string {
|
||||||
|
if (!ts) return "";
|
||||||
|
const n = Number(ts);
|
||||||
|
const d = new Date(n > 1e10 ? n : n * 1000);
|
||||||
|
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChapters(nodes: Chapter[]) {
|
||||||
|
chapters = nodes;
|
||||||
|
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortDir = $derived(store.settings.chapterSortDir);
|
||||||
|
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
|
||||||
|
let sortMenuOpen = $state(false);
|
||||||
|
|
||||||
|
const sortedChapters = $derived.by(() => {
|
||||||
|
const base = [...chapters];
|
||||||
|
if (sortMode === "chapterNumber") {
|
||||||
|
base.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
||||||
|
} else if (sortMode === "uploadDate") {
|
||||||
|
base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
|
||||||
|
} else {
|
||||||
|
base.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
}
|
||||||
|
return sortDir === "desc" ? base.reverse() : base;
|
||||||
|
});
|
||||||
|
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
||||||
|
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
||||||
|
const readCount = $derived(chapters.filter(c => c.isRead).length);
|
||||||
|
const totalCount = $derived(chapters.length);
|
||||||
|
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
||||||
|
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
|
||||||
|
|
||||||
|
const continueChapter = $derived((() => {
|
||||||
|
if (!chapters.length) return null;
|
||||||
|
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
const anyRead = asc.some(c => c.isRead);
|
||||||
|
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
|
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
||||||
|
const firstUnread = asc.find(c => !c.isRead);
|
||||||
|
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const };
|
||||||
|
return { chapter: asc[0], type: "reread" as const };
|
||||||
|
})());
|
||||||
|
|
||||||
|
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||||
|
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
|
||||||
|
const hasFolders = $derived(assignedFolders.length > 0);
|
||||||
|
|
||||||
|
function loadManga(id: number) {
|
||||||
|
mangaAbort?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
mangaAbort = ctrl;
|
||||||
|
loadingFor = id;
|
||||||
|
const cached = mangaStore.get(id);
|
||||||
|
if (cached) {
|
||||||
|
manga = cached.data; loadingManga = false;
|
||||||
|
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return;
|
||||||
|
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
|
||||||
|
if (ctrl.signal.aborted || loadingFor !== id) return;
|
||||||
|
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
|
||||||
|
manga = d.manga;
|
||||||
|
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingManga = true;
|
||||||
|
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
|
||||||
|
if (ctrl.signal.aborted || loadingFor !== id) return;
|
||||||
|
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
|
||||||
|
manga = d.manga;
|
||||||
|
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
|
||||||
|
}).catch(() => {}).finally(() => { if (!ctrl.signal.aborted && loadingFor === id) loadingManga = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChapters(id: number) {
|
||||||
|
chapterAbort?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
chapterAbort = ctrl;
|
||||||
|
const cached = chapterStore.get(id);
|
||||||
|
if (cached) {
|
||||||
|
applyChapters(cached.data); loadingChapters = false;
|
||||||
|
if (Date.now() - cached.fetchedAt < CHAPTER_TTL_MS) return;
|
||||||
|
gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
|
||||||
|
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
|
||||||
|
.then(d => {
|
||||||
|
if (ctrl.signal.aborted || loadingFor !== id) return;
|
||||||
|
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
|
||||||
|
applyChapters(d.chapters.nodes);
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chapters = []; loadingChapters = true;
|
||||||
|
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal).then(d => {
|
||||||
|
if (ctrl.signal.aborted || loadingFor !== id) return;
|
||||||
|
applyChapters(d.chapters.nodes); loadingChapters = false;
|
||||||
|
return gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
|
||||||
|
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
|
||||||
|
.then(fresh => {
|
||||||
|
if (ctrl.signal.aborted || loadingFor !== id) return;
|
||||||
|
chapterStore.set(id, { data: fresh.chapters.nodes, fetchedAt: Date.now() });
|
||||||
|
applyChapters(fresh.chapters.nodes);
|
||||||
|
});
|
||||||
|
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const m = store.activeManga;
|
||||||
|
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); });
|
||||||
|
});
|
||||||
|
|
||||||
|
let prevChapterId: number | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const wasOpen = prevChapterId !== null;
|
||||||
|
prevChapterId = store.activeChapter?.id ?? null;
|
||||||
|
if (wasOpen && !store.activeChapter && store.activeManga) {
|
||||||
|
const id = store.activeManga.id;
|
||||||
|
untrack(() => { loadChapters(id); cache.clear(CACHE_KEYS.LIBRARY); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleLibrary() {
|
||||||
|
if (!manga) return;
|
||||||
|
togglingLibrary = true;
|
||||||
|
const next = !manga.inLibrary;
|
||||||
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||||
|
manga = { ...manga, inLibrary: next };
|
||||||
|
if (mangaStore.has(manga.id)) { const e = mangaStore.get(manga.id)!; mangaStore.set(manga.id, { ...e, data: manga }); }
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
togglingLibrary = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadChapters(id: number) {
|
||||||
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id });
|
||||||
|
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
|
||||||
|
applyChapters(d.chapters.nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueue(ch: Chapter, e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
enqueueing = new Set(enqueueing).add(ch.id);
|
||||||
|
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
|
||||||
|
addToast({ kind: "download", title: "Download queued", body: ch.name });
|
||||||
|
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
||||||
|
if (store.activeManga) reloadChapters(store.activeManga.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueMultiple(chapterIds: number[]) {
|
||||||
|
if (!chapterIds.length) return;
|
||||||
|
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
||||||
|
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
|
||||||
|
if (store.activeManga) reloadChapters(store.activeManga.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(chapterId: number, isRead: boolean) {
|
||||||
|
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||||
|
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
||||||
|
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markBulk(ids: number[], isRead: boolean) {
|
||||||
|
if (!ids.length) return;
|
||||||
|
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
||||||
|
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
|
||||||
|
const markBelowRead = (i: number) => markBulk(sortedChapters.slice(i).filter(c => !c.isRead).map(c => c.id), true);
|
||||||
|
const markAboveUnread = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => c.isRead).map(c => c.id), false);
|
||||||
|
const markBelowUnread = (i: number) => markBulk(sortedChapters.slice(i).filter(c => c.isRead).map(c => c.id), false);
|
||||||
|
|
||||||
|
async function deleteDownloaded(chapterId: number) {
|
||||||
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
||||||
|
chapters = chapters.map(c => c.id === chapterId ? { ...c, isDownloaded: false } : c);
|
||||||
|
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAllDownloads() {
|
||||||
|
const ids = chapters.filter(c => c.isDownloaded).map(c => c.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
deletingAll = true;
|
||||||
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
||||||
|
chapters = chapters.map(c => ({ ...c, isDownloaded: false }));
|
||||||
|
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||||
|
deletingAll = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshChapters() {
|
||||||
|
if (!store.activeManga || refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
chapterStore.delete(store.activeManga.id);
|
||||||
|
gql(FETCH_CHAPTERS, { mangaId: store.activeManga.id })
|
||||||
|
.then(() => reloadChapters(store.activeManga!.id))
|
||||||
|
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
|
||||||
|
.catch(e => addToast({ kind: "error", title: "Refresh failed", body: e?.message }))
|
||||||
|
.finally(() => refreshing = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
|
||||||
|
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1;
|
||||||
|
return [
|
||||||
|
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
|
||||||
|
{ separator: true },
|
||||||
|
{ label: "Mark above as read", icon: CheckCircle, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
|
||||||
|
{ label: "Mark above as unread", icon: Circle, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
|
||||||
|
{ separator: true },
|
||||||
|
{ label: "Mark below as read", icon: CheckCircle, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
|
||||||
|
{ label: "Mark below as unread", icon: Circle, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
||||||
|
{ separator: true },
|
||||||
|
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) },
|
||||||
|
{ separator: true },
|
||||||
|
{ label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
|
||||||
|
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
|
||||||
|
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
|
||||||
|
else document.removeEventListener("mousedown", handleDlOutside, true);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
|
||||||
|
else document.removeEventListener("mousedown", handleFolderOutside, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
function enqueueNext(n: number) {
|
||||||
|
if (!continueChapter) return;
|
||||||
|
const idx = sortedChapters.indexOf(continueChapter.chapter);
|
||||||
|
if (idx < 0) return;
|
||||||
|
enqueueMultiple(sortedChapters.slice(idx, idx + n).filter(c => !c.isDownloaded).map(c => c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueRange() {
|
||||||
|
const from = parseFloat(rangeFrom), to = parseFloat(rangeTo);
|
||||||
|
if (isNaN(from) || isNaN(to)) return;
|
||||||
|
const lo = Math.min(from, to), hi = Math.max(from, to);
|
||||||
|
enqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFolder() {
|
||||||
|
const name = folderNewName.trim();
|
||||||
|
if (!name || !store.activeManga) return;
|
||||||
|
const id = addFolder(name);
|
||||||
|
assignMangaToFolder(id, store.activeManga.id);
|
||||||
|
folderNewName = ""; folderCreating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||||
|
|
||||||
|
// ── Series link ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const linkedIds = $derived(
|
||||||
|
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkPickerResults = $derived.by(() => {
|
||||||
|
const id = store.activeManga?.id;
|
||||||
|
const others = allMangaForLink.filter(m => m.id !== id);
|
||||||
|
const q = linkSearch.trim().toLowerCase();
|
||||||
|
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
||||||
|
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
||||||
|
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
||||||
|
return [...linked, ...rest];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openLinkPicker() {
|
||||||
|
linkPickerOpen = true; linkSearch = "";
|
||||||
|
if (allMangaForLink.length) return;
|
||||||
|
loadingLinkList = true;
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||||
|
.then(d => { allMangaForLink = d.mangas.nodes; })
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => { loadingLinkList = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
||||||
|
|
||||||
|
function handleLink(other: Manga) {
|
||||||
|
if (!store.activeManga) return;
|
||||||
|
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
|
||||||
|
else linkManga(store.activeManga.id, other.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if store.activeManga}
|
||||||
|
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
|
||||||
|
|
||||||
|
<!-- ── Sidebar ────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<button class="back" onclick={() => setActiveManga(null)}>
|
||||||
|
<ArrowLeft size={13} weight="light" /> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Zone 1: Cover -->
|
||||||
|
<div class="cover-wrap">
|
||||||
|
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zone 2: Meta -->
|
||||||
|
{#if loadingManga}
|
||||||
|
<div class="meta-skeleton">
|
||||||
|
<div class="skeleton sk-line" style="width:90%;height:14px"></div>
|
||||||
|
<div class="skeleton sk-line" style="width:60%;height:11px"></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="meta">
|
||||||
|
<p class="title">{manga?.title}</p>
|
||||||
|
{#if manga?.author || manga?.artist}
|
||||||
|
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
|
||||||
|
{/if}
|
||||||
|
{#if statusLabel}
|
||||||
|
<span class="status-badge" class:ongoing={manga?.status === "ONGOING"} class:ended={manga?.status !== "ONGOING"}>{statusLabel}</span>
|
||||||
|
{/if}
|
||||||
|
{#if manga?.genre?.length}
|
||||||
|
<div class="genres">
|
||||||
|
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g}
|
||||||
|
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button>
|
||||||
|
{/each}
|
||||||
|
{#if manga.genre.length > 3}
|
||||||
|
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
|
||||||
|
{genresExpanded ? "less" : `+${manga.genre.length - 3}`}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if manga?.description}
|
||||||
|
<p class="desc">{manga.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Zone 3: Primary CTA + library action -->
|
||||||
|
<div class="cta-section">
|
||||||
|
{#if continueChapter}
|
||||||
|
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, sortedChapters)}>
|
||||||
|
<Play size={12} weight="fill" />
|
||||||
|
{continueChapter.type === "continue"
|
||||||
|
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
||||||
|
: continueChapter.type === "reread" ? "Read again" : "Start reading"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button class="library-btn" class:active={manga?.inLibrary} onclick={toggleLibrary} disabled={togglingLibrary || loadingManga}>
|
||||||
|
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
|
||||||
|
{manga?.inLibrary ? "In Library" : "Add to Library"}
|
||||||
|
</button>
|
||||||
|
{#if manga?.realUrl}
|
||||||
|
<a href={manga.realUrl} target="_blank" rel="noreferrer" class="external-link">
|
||||||
|
<ArrowSquareOut size={13} weight="light" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zone 4: Progress -->
|
||||||
|
{#if totalCount > 0}
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-header">
|
||||||
|
<span class="progress-label">{readCount} / {totalCount} read</span>
|
||||||
|
<span class="progress-pct">{Math.round(progressPct)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track"><div class="progress-fill" style="width:{progressPct}%"></div></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Zone 5: Details accordion (source info + all secondary actions) -->
|
||||||
|
{#if !loadingManga && manga?.source}
|
||||||
|
<div class="details-section">
|
||||||
|
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}>
|
||||||
|
<span>Details</span>
|
||||||
|
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
|
||||||
|
</button>
|
||||||
|
{#if detailsOpen}
|
||||||
|
<div class="details-body">
|
||||||
|
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
|
||||||
|
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
|
||||||
|
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
|
||||||
|
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
|
||||||
|
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="detail-action-btn" onclick={() => migrateOpen = true}>
|
||||||
|
<ArrowsClockwise size={12} weight="light" /> Switch Source
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="detail-action-btn"
|
||||||
|
class:detail-action-active={linkedIds.length > 0}
|
||||||
|
onclick={openLinkPicker}
|
||||||
|
>
|
||||||
|
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
||||||
|
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="detail-action-btn"
|
||||||
|
onclick={() => trackingOpen = true}
|
||||||
|
>
|
||||||
|
<ChartLineUp size={12} weight="light" /> Tracking
|
||||||
|
</button>
|
||||||
|
{#if downloadedCount > 0}
|
||||||
|
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
|
||||||
|
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
|
||||||
|
<div class="list-wrap">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="list-header-left">
|
||||||
|
<div class="sort-wrap">
|
||||||
|
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
|
||||||
|
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
||||||
|
{{ source: "Source order", chapterNumber: "Ch. number", uploadDate: "Upload date" }[sortMode]}
|
||||||
|
<CaretDown size={10} weight="light" />
|
||||||
|
</button>
|
||||||
|
{#if sortMenuOpen}
|
||||||
|
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
|
||||||
|
{#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]}
|
||||||
|
<button class="sort-option" class:active={sortMode === val}
|
||||||
|
onclick={() => { updateSettings({ chapterSortMode: val as any }); chapterPage = 1; sortMenuOpen = false; }}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="sort-divider"></div>
|
||||||
|
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; sortMenuOpen = false; }}>
|
||||||
|
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!-- View toggle: icon reflects current state -->
|
||||||
|
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
|
||||||
|
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="list-header-right">
|
||||||
|
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
|
||||||
|
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Folder picker -->
|
||||||
|
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||||||
|
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
||||||
|
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
||||||
|
</button>
|
||||||
|
{#if folderPickerOpen}
|
||||||
|
<div class="fp-menu">
|
||||||
|
{#if store.settings.folders.length === 0 && !folderCreating}
|
||||||
|
<p class="fp-empty">No folders yet</p>
|
||||||
|
{/if}
|
||||||
|
{#each store.settings.folders as folder}
|
||||||
|
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false}
|
||||||
|
<button class="fp-item" class:fp-item-active={isIn}
|
||||||
|
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}>
|
||||||
|
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="fp-div"></div>
|
||||||
|
{#if folderCreating}
|
||||||
|
<div class="fp-create">
|
||||||
|
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
|
||||||
|
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
|
||||||
|
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
||||||
|
<X size={12} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download dropdown -->
|
||||||
|
{#if chapters.length > 0}
|
||||||
|
<div class="dl-wrap" bind:this={dlDropRef}>
|
||||||
|
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}>
|
||||||
|
<Download size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
{#if dlOpen}
|
||||||
|
<div class="dl-dropdown">
|
||||||
|
{#if continueChapter}
|
||||||
|
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
|
||||||
|
{#if contIdx >= 0}
|
||||||
|
<p class="dl-section-label">From Ch.{continueChapter.chapter.chapterNumber}</p>
|
||||||
|
<div class="dl-next-row">
|
||||||
|
{#each [5, 10, 25] as n}
|
||||||
|
{@const avail = sortedChapters.slice(contIdx, contIdx + n).filter(c => !c.isDownloaded).length}
|
||||||
|
<button class="dl-next-btn" disabled={avail === 0} onclick={() => { enqueueNext(n); dlOpen = false; }}>
|
||||||
|
<span>Next {n}</span><span class="dl-next-sub">{avail} new</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="dl-divider"></div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if !showRange}
|
||||||
|
<button class="dl-item" onclick={() => showRange = true}>
|
||||||
|
<span>Custom range…</span><span class="dl-item-sub">Enter chapter numbers</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="dl-range-row">
|
||||||
|
<button class="dl-range-back" onclick={() => showRange = false}>‹</button>
|
||||||
|
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} use:focusOnMount />
|
||||||
|
<span class="dl-range-sep">–</span>
|
||||||
|
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
|
||||||
|
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="dl-divider"></div>
|
||||||
|
<button class="dl-item" onclick={() => { enqueueMultiple(sortedChapters.filter(c => !c.isRead && !c.isDownloaded).map(c => c.id)); dlOpen = false; }}>
|
||||||
|
<span>Unread chapters</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isRead && !c.isDownloaded).length} remaining</span>
|
||||||
|
</button>
|
||||||
|
<button class="dl-item" onclick={() => { enqueueMultiple(sortedChapters.filter(c => !c.isDownloaded).map(c => c.id)); dlOpen = false; }}>
|
||||||
|
<span>Download all</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isDownloaded).length} not downloaded</span>
|
||||||
|
</button>
|
||||||
|
{#if downloadedCount > 0}
|
||||||
|
<div class="dl-divider"></div>
|
||||||
|
<button class="dl-item dl-item-danger" onclick={() => { deleteAllDownloads(); dlOpen = false; }} disabled={deletingAll}>
|
||||||
|
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
||||||
|
<span class="dl-item-sub">{downloadedCount} downloaded</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="pagination">
|
||||||
|
<button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}>←</button>
|
||||||
|
<span class="page-num">{chapterPage} / {totalPages}</span>
|
||||||
|
<button class="page-btn" onclick={() => chapterPage = Math.min(totalPages, chapterPage + 1)} disabled={chapterPage === totalPages}>→</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}>
|
||||||
|
{#if loadingChapters && chapters.length === 0}
|
||||||
|
{#if viewMode === "grid"}
|
||||||
|
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
||||||
|
{:else}
|
||||||
|
{#each Array(8) as _}<div class="row-skeleton"><div class="skeleton sk-line" style="width:55%;height:12px"></div><div class="skeleton sk-line" style="width:25%;height:11px"></div></div>{/each}
|
||||||
|
{/if}
|
||||||
|
{:else if viewMode === "grid"}
|
||||||
|
{#each sortedChapters as ch, i}
|
||||||
|
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||||
|
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress}
|
||||||
|
onclick={() => openReader(ch, sortedChapters)}
|
||||||
|
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
||||||
|
title={ch.name}>
|
||||||
|
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
||||||
|
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
||||||
|
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each pageChapters as ch}
|
||||||
|
{@const idxInSorted = sortedChapters.indexOf(ch)}
|
||||||
|
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead}
|
||||||
|
onclick={() => openReader(ch, sortedChapters)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)}
|
||||||
|
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
||||||
|
<div class="ch-left">
|
||||||
|
<span class="ch-name">{ch.name}</span>
|
||||||
|
<div class="ch-meta">
|
||||||
|
{#if ch.scanlator}<span class="ch-meta-item">{ch.scanlator}</span>{/if}
|
||||||
|
{#if ch.uploadDate}<span class="ch-meta-item">{formatDate(ch.uploadDate)}</span>{/if}
|
||||||
|
{#if ch.lastPageRead && ch.lastPageRead > 0 && !ch.isRead}<span class="ch-meta-item">p.{ch.lastPageRead}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-right">
|
||||||
|
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
||||||
|
{#if ch.isDownloaded}
|
||||||
|
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
|
||||||
|
{:else if enqueueing.has(ch.id)}
|
||||||
|
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
||||||
|
{:else}
|
||||||
|
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="pagination-bottom">
|
||||||
|
<button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}>← Prev</button>
|
||||||
|
<span class="page-num">{chapterPage} / {totalPages}</span>
|
||||||
|
<button class="page-btn" onclick={() => chapterPage = Math.min(totalPages, chapterPage + 1)} disabled={chapterPage === totalPages}>Next →</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if ctx}
|
||||||
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.chapter, ctx.idx)} onClose={() => ctx = null} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if migrateOpen && manga}
|
||||||
|
<MigrateModal
|
||||||
|
{manga}
|
||||||
|
currentChapters={chapters}
|
||||||
|
onClose={() => migrateOpen = false}
|
||||||
|
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if trackingOpen && store.activeManga}
|
||||||
|
<TrackingPanel
|
||||||
|
mangaId={store.activeManga.id}
|
||||||
|
mangaTitle={store.activeManga.title}
|
||||||
|
onClose={() => trackingOpen = false}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if linkPickerOpen}
|
||||||
|
<div
|
||||||
|
class="link-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
||||||
|
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}
|
||||||
|
>
|
||||||
|
<div class="link-modal">
|
||||||
|
<div class="link-header">
|
||||||
|
<span class="link-title">Link as same series</span>
|
||||||
|
<button class="link-close" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
<p class="link-hint">Mark two manga as the same series so duplicates are merged in search and discover. Click a linked entry again to unlink.</p>
|
||||||
|
<div class="link-search-wrap">
|
||||||
|
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusOnMount />
|
||||||
|
</div>
|
||||||
|
<div class="link-list">
|
||||||
|
{#if loadingLinkList}
|
||||||
|
<p class="link-empty">Loading…</p>
|
||||||
|
{:else if linkPickerResults.length === 0}
|
||||||
|
<p class="link-empty">No results</p>
|
||||||
|
{:else}
|
||||||
|
{#each linkPickerResults as m (m.id)}
|
||||||
|
{@const isLinked = linkedIds.includes(m.id)}
|
||||||
|
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
||||||
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
|
||||||
|
<div class="link-info">
|
||||||
|
<span class="link-manga-title">{m.title}</span>
|
||||||
|
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
|
||||||
|
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
|
||||||
|
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
||||||
|
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); }
|
||||||
|
.back:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Zone 1: Cover */
|
||||||
|
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.cover { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
|
/* Zone 2: Meta */
|
||||||
|
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.sk-line { border-radius: var(--radius-sm); }
|
||||||
|
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); line-height: var(--leading-snug); letter-spacing: var(--tracking-tight); }
|
||||||
|
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
||||||
|
.status-badge { display: inline-block; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-sm); width: fit-content; }
|
||||||
|
.status-badge.ongoing { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||||||
|
.status-badge.ended { background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim); }
|
||||||
|
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
.genre { font-size: var(--text-2xs); font-family: var(--font-ui); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
|
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
/* Description clamped — no expand in 240px sidebar */
|
||||||
|
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Zone 3: CTA */
|
||||||
|
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.read-btn:hover { opacity: 0.88; }
|
||||||
|
.actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.library-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); background: var(--bg-raised); transition: border-color var(--t-base), color var(--t-base), background var(--t-base); flex: 1; }
|
||||||
|
.library-btn:hover { border-color: var(--accent); color: var(--accent-fg); }
|
||||||
|
.library-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
|
.library-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
/* Zone 4: Progress */
|
||||||
|
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
|
.progress-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||||||
|
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
||||||
|
|
||||||
|
/* Zone 5: Details accordion */
|
||||||
|
.details-section { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
|
||||||
|
.details-toggle:hover { color: var(--text-muted); }
|
||||||
|
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
|
||||||
|
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
|
||||||
|
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
|
||||||
|
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
|
||||||
|
.detail-action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.detail-action-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
.detail-action-active:hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||||
|
.detail-action-danger { color: var(--color-error); }
|
||||||
|
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
|
||||||
|
.detail-action-danger:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
/* ── Series link modal ───────────────────────────────────────────────────── */
|
||||||
|
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
|
||||||
|
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
||||||
|
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||||
|
.link-close { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.link-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.link-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0; }
|
||||||
|
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.link-search { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
||||||
|
.link-search:focus { border-color: var(--border-strong); }
|
||||||
|
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
||||||
|
.link-list::-webkit-scrollbar { display: none; }
|
||||||
|
.link-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; letter-spacing: var(--tracking-wide); }
|
||||||
|
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||||
|
.link-row:hover { background: var(--bg-raised); }
|
||||||
|
.link-row-linked { background: var(--accent-muted) !important; }
|
||||||
|
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
|
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
|
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
||||||
|
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
|
||||||
|
/* ── Chapter list ────────────────────────────────────────────────────────── */
|
||||||
|
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
|
.sort-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.sort-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||||||
|
.sort-wrap { position: relative; }
|
||||||
|
.sort-menu { position: absolute; top: calc(100% + 4px); left: 0; min-width: 160px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top left; }
|
||||||
|
.sort-option { display: block; width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
||||||
|
.sort-option:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
.sort-option.active { color: var(--accent-fg); }
|
||||||
|
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||||||
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
||||||
|
.fp-wrap { position: relative; }
|
||||||
|
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||||
|
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
|
||||||
|
.fp-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
||||||
|
.fp-item:hover { background: var(--bg-overlay); }
|
||||||
|
.fp-item.fp-item-active { color: var(--accent-fg); }
|
||||||
|
.fp-check { width: 12px; font-size: var(--text-xs); color: var(--accent-fg); flex-shrink: 0; }
|
||||||
|
.fp-div { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||||||
|
.fp-create { display: flex; align-items: center; gap: var(--sp-1); padding: 4px var(--sp-2); }
|
||||||
|
.fp-input { flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; min-width: 0; }
|
||||||
|
.fp-input:focus { border-color: var(--border-focus); }
|
||||||
|
.fp-confirm { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; }
|
||||||
|
.fp-confirm:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.fp-cancel { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
|
.fp-cancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
||||||
|
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
|
||||||
|
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
/* ── Download dropdown ───────────────────────────────────────────────────── */
|
||||||
|
.dl-wrap { position: relative; }
|
||||||
|
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||||
|
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.dl-next-row { display: flex; gap: 4px; padding: 2px var(--sp-2) var(--sp-2); }
|
||||||
|
.dl-next-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 5px; padding: 5px 6px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); }
|
||||||
|
.dl-next-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
|
.dl-next-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
.dl-next-sub { font-size: var(--text-2xs); color: var(--text-faint); }
|
||||||
|
.dl-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||||||
|
.dl-item { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
||||||
|
.dl-item:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
.dl-item:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
.dl-item.dl-item-danger { color: var(--color-error); }
|
||||||
|
.dl-item.dl-item-danger:hover:not(:disabled) { background: var(--color-error-bg); }
|
||||||
|
.dl-item-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
||||||
|
.dl-range-row { display: flex; align-items: center; gap: 4px; padding: 7px var(--sp-2); }
|
||||||
|
.dl-range-back { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 14px; cursor: pointer; }
|
||||||
|
.dl-range-back:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
.dl-range-input { flex: 1; min-width: 0; padding: 4px 8px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); outline: none; text-align: center; }
|
||||||
|
.dl-range-input:focus { border-color: var(--border-focus); }
|
||||||
|
.dl-range-sep { color: var(--text-faint); font-size: var(--text-xs); }
|
||||||
|
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
|
||||||
|
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
/* ── Pagination ──────────────────────────────────────────────────────────── */
|
||||||
|
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
|
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
/* ── Chapter rows ────────────────────────────────────────────────────────── */
|
||||||
|
.ch-list { flex: 1; overflow-y: auto; }
|
||||||
|
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
||||||
|
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
||||||
|
.ch-row:hover { background: var(--bg-raised); }
|
||||||
|
.ch-row.read { opacity: 0.45; }
|
||||||
|
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.ch-name { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
.ch-meta-item { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.ch-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
|
:global(.read-icon) { color: var(--text-faint); }
|
||||||
|
:global(.enqueue-icon) { color: var(--text-faint); }
|
||||||
|
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); opacity: 0; }
|
||||||
|
.ch-row:hover .dl-btn { opacity: 1; }
|
||||||
|
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
.row-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); padding: 12px var(--sp-4); border-bottom: 1px solid var(--border-dim); }
|
||||||
|
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
|
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
|
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
|
||||||
|
.grid-cell.in-progress { border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
|
.grid-cell-num { font-size: 10px; }
|
||||||
|
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
|
||||||
|
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
||||||
|
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script module>
|
||||||
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
|
</script>
|
||||||
@@ -1,744 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
|
||||||
import {
|
|
||||||
ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle,
|
|
||||||
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
|
||||||
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
|
||||||
List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import {
|
|
||||||
GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD,
|
|
||||||
UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS,
|
|
||||||
ENQUEUE_CHAPTERS_DOWNLOAD,
|
|
||||||
} from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
|
||||||
import MigrateModal from "./MigrateModal";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import s from "./SeriesDetail.module.css";
|
|
||||||
|
|
||||||
function formatDate(ts: string | null | undefined): string {
|
|
||||||
if (!ts) return "";
|
|
||||||
const n = Number(ts);
|
|
||||||
const d = new Date(n > 1e10 ? n : n * 1000);
|
|
||||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CtxState {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
chapter: Chapter;
|
|
||||||
indexInSorted: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25;
|
|
||||||
|
|
||||||
// ── Folder picker (icon button for list header) ───────────────────────────────
|
|
||||||
function FolderPicker({ mangaId }: { mangaId: number }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const folders = useStore((st) => st.settings.folders);
|
|
||||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
|
||||||
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
|
||||||
const addFolder = useStore((st) => st.addFolder);
|
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
|
|
||||||
const assigned = folders.filter((f) => f.mangaIds.includes(mangaId));
|
|
||||||
const hasAssigned = assigned.length > 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
setCreating(false);
|
|
||||||
setNewName("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
return () => document.removeEventListener("mousedown", handler);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
function handleCreate() {
|
|
||||||
const name = newName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const id = addFolder(name);
|
|
||||||
assignMangaToFolder(id, mangaId);
|
|
||||||
setNewName("");
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.folderPickerWrap} ref={ref}>
|
|
||||||
<button
|
|
||||||
className={[s.folderPickerBtn, hasAssigned ? s.folderPickerBtnActive : ""].join(" ")}
|
|
||||||
onClick={() => setOpen((p) => !p)}
|
|
||||||
title={hasAssigned ? `Folders: ${assigned.map((f) => f.name).join(", ")}` : "Add to folder"}
|
|
||||||
>
|
|
||||||
<FolderSimplePlus size={14} weight={hasAssigned ? "fill" : "light"} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className={s.folderPickerMenu}>
|
|
||||||
{folders.length === 0 && !creating && (
|
|
||||||
<p className={s.folderPickerEmpty}>No folders yet</p>
|
|
||||||
)}
|
|
||||||
{folders.map((folder) => {
|
|
||||||
const isIn = folder.mangaIds.includes(mangaId);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={folder.id}
|
|
||||||
className={[s.folderPickerItem, isIn ? s.folderPickerItemActive : ""].join(" ")}
|
|
||||||
onClick={() =>
|
|
||||||
isIn
|
|
||||||
? removeMangaFromFolder(folder.id, mangaId)
|
|
||||||
: assignMangaToFolder(folder.id, mangaId)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className={s.folderPickerItemCheck}>{isIn ? "✓" : ""}</span>
|
|
||||||
{folder.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className={s.folderPickerDivider} />
|
|
||||||
{creating ? (
|
|
||||||
<div className={s.folderPickerCreate}>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className={s.folderPickerInput}
|
|
||||||
placeholder="Folder name…"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleCreate();
|
|
||||||
if (e.key === "Escape") { setCreating(false); setNewName(""); }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button className={s.folderPickerConfirm} onClick={handleCreate} disabled={!newName.trim()}>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button className={s.folderPickerCancel} onClick={() => { setCreating(false); setNewName(""); }}>
|
|
||||||
<X size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button className={s.folderPickerNewBtn} onClick={() => setCreating(true)}>
|
|
||||||
+ New folder
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
|
||||||
export default function SeriesDetail() {
|
|
||||||
const activeManga = useStore((state) => state.activeManga);
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
|
||||||
const openReader = useStore((state) => state.openReader);
|
|
||||||
const settings = useStore((state) => state.settings);
|
|
||||||
const updateSettings = useStore((state) => state.updateSettings);
|
|
||||||
|
|
||||||
const [manga, setManga] = useState<Manga | null>(activeManga);
|
|
||||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
|
||||||
const [loadingManga, setLoadingManga] = useState(true);
|
|
||||||
const [loadingChapters, setLoadingChapters] = useState(true);
|
|
||||||
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
|
|
||||||
const [dlOpen, setDlOpen] = useState(false);
|
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
||||||
const [migrateOpen, setMigrateOpen] = useState(false);
|
|
||||||
const [togglingLibrary, setTogglingLibrary] = useState(false);
|
|
||||||
const [chapterPage, setChapterPage] = useState(1);
|
|
||||||
const [ctx, setCtx] = useState<CtxState | null>(null);
|
|
||||||
const [jumpOpen, setJumpOpen] = useState(false);
|
|
||||||
const [jumpInput, setJumpInput] = useState("");
|
|
||||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
|
||||||
|
|
||||||
const sortDir = settings.chapterSortDir;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeManga) return;
|
|
||||||
setLoadingManga(true);
|
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id: activeManga.id })
|
|
||||||
.then((data) => setManga(data.manga))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoadingManga(false));
|
|
||||||
}, [activeManga?.id]);
|
|
||||||
|
|
||||||
const loadChapters = useCallback((mangaId: number) => {
|
|
||||||
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId })
|
|
||||||
.then((data) => {
|
|
||||||
const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
setChapters(sorted);
|
|
||||||
return sorted;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeManga) return;
|
|
||||||
setLoadingChapters(true);
|
|
||||||
setChapters([]);
|
|
||||||
setChapterPage(1);
|
|
||||||
|
|
||||||
loadChapters(activeManga.id)
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoadingChapters(false));
|
|
||||||
|
|
||||||
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
|
||||||
.then(() => loadChapters(activeManga.id))
|
|
||||||
.catch(console.error);
|
|
||||||
}, [activeManga?.id]);
|
|
||||||
|
|
||||||
const sortedChapters = useMemo(() =>
|
|
||||||
sortDir === "desc" ? [...chapters].reverse() : [...chapters],
|
|
||||||
[chapters, sortDir]
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE);
|
|
||||||
const pageChapters = sortedChapters.slice(
|
|
||||||
(chapterPage - 1) * CHAPTERS_PER_PAGE,
|
|
||||||
chapterPage * CHAPTERS_PER_PAGE
|
|
||||||
);
|
|
||||||
|
|
||||||
const readCount = chapters.filter((c) => c.isRead).length;
|
|
||||||
const totalCount = chapters.length;
|
|
||||||
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
|
|
||||||
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
|
||||||
|
|
||||||
const continueChapter = useMemo(() => {
|
|
||||||
if (!chapters.length) return null;
|
|
||||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const anyRead = asc.some((c) => c.isRead);
|
|
||||||
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
|
||||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
|
||||||
const firstUnread = asc.find((c) => !c.isRead);
|
|
||||||
if (firstUnread) return { chapter: firstUnread, type: anyRead ? "continue" : "start" as const };
|
|
||||||
return { chapter: asc[0], type: "reread" as const };
|
|
||||||
}, [chapters]);
|
|
||||||
|
|
||||||
async function toggleLibrary() {
|
|
||||||
if (!manga) return;
|
|
||||||
setTogglingLibrary(true);
|
|
||||||
const next = !manga.inLibrary;
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
|
||||||
setManga((prev) => prev ? { ...prev, inLibrary: next } : prev);
|
|
||||||
setTogglingLibrary(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
|
||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error);
|
|
||||||
setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; });
|
|
||||||
if (activeManga) loadChapters(activeManga.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markRead(chapterId: number, isRead: boolean) {
|
|
||||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
|
||||||
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isRead } : c));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markAllAboveRead(indexInSorted: number) {
|
|
||||||
const targets = sortedChapters.slice(0, indexInSorted + 1);
|
|
||||||
const ids = targets.filter((c) => !c.isRead).map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
await gql(MARK_CHAPTERS_READ, { ids, isRead: true }).catch(console.error);
|
|
||||||
setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead: true } : c));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteDownloaded(chapterId: number) {
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
|
||||||
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads() {
|
|
||||||
const ids = chapters.filter((c) => c.isDownloaded).map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
setDeletingAll(true);
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
|
||||||
setChapters((prev) => prev.map((c) => ({ ...c, isDownloaded: false })));
|
|
||||||
setDeletingAll(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
|
||||||
if (activeManga) loadChapters(activeManga.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) {
|
|
||||||
e.preventDefault();
|
|
||||||
setCtx({ x: e.clientX, y: e.clientY, chapter, indexInSorted });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: ch.isRead ? "Mark as unread" : "Mark as read",
|
|
||||||
icon: ch.isRead
|
|
||||||
? <Circle size={13} weight="light" />
|
|
||||||
: <CheckCircle size={13} weight="light" />,
|
|
||||||
onClick: () => markRead(ch.id, !ch.isRead),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Mark all above as read",
|
|
||||||
icon: <CheckCircle size={13} weight="duotone" />,
|
|
||||||
onClick: () => markAllAboveRead(indexInSorted),
|
|
||||||
disabled: indexInSorted === 0,
|
|
||||||
},
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: ch.isDownloaded ? "Delete download" : "Download",
|
|
||||||
icon: ch.isDownloaded
|
|
||||||
? <Trash size={13} weight="light" />
|
|
||||||
: <Download size={13} weight="light" />,
|
|
||||||
onClick: () => ch.isDownloaded
|
|
||||||
? deleteDownloaded(ch.id)
|
|
||||||
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
|
|
||||||
danger: ch.isDownloaded,
|
|
||||||
},
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: "Download all from here",
|
|
||||||
icon: <DownloadSimple size={13} weight="light" />,
|
|
||||||
onClick: () => {
|
|
||||||
const fromHere = sortedChapters
|
|
||||||
.slice(indexInSorted)
|
|
||||||
.filter((c) => !c.isDownloaded)
|
|
||||||
.map((c) => c.id);
|
|
||||||
enqueueMultiple(fromHere);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!activeManga) return null;
|
|
||||||
|
|
||||||
const statusLabel = manga?.status
|
|
||||||
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root} onContextMenu={(e) => e.preventDefault()}>
|
|
||||||
{/* ── Sidebar ── */}
|
|
||||||
<div className={s.sidebar}>
|
|
||||||
<button className={s.back} onClick={() => setActiveManga(null)}>
|
|
||||||
<ArrowLeft size={13} weight="light" />
|
|
||||||
<span>Library</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<img src={thumbUrl(activeManga.thumbnailUrl)} alt={activeManga.title} className={s.cover} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingManga ? (
|
|
||||||
<div className={s.metaSkeleton}>
|
|
||||||
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "90%", height: 14 }} />
|
|
||||||
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "60%", height: 11 }} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.meta}>
|
|
||||||
<p className={s.title}>{manga?.title}</p>
|
|
||||||
|
|
||||||
{(manga?.author || manga?.artist) && (
|
|
||||||
<p className={s.byline}>
|
|
||||||
{[manga.author, manga.artist]
|
|
||||||
.filter(Boolean)
|
|
||||||
.filter((v, i, a) => a.indexOf(v) === i)
|
|
||||||
.join(" · ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{statusLabel && (
|
|
||||||
<span className={[s.statusBadge, manga?.status === "ONGOING" ? s.statusOngoing : s.statusEnded].join(" ").trim()}>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{manga?.genre && manga.genre.length > 0 && (
|
|
||||||
<div className={s.genres}>
|
|
||||||
{manga.genre.map((g) => <span key={g} className={s.genre}>{g}</span>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{manga?.description && <p className={s.description}>{manga.description}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
{totalCount > 0 && (
|
|
||||||
<div className={s.progressSection}>
|
|
||||||
<div className={s.progressHeader}>
|
|
||||||
<span className={s.progressLabel}>{readCount} / {totalCount} read</span>
|
|
||||||
<span className={s.progressPct}>{Math.round(progressPct)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.progressTrack}>
|
|
||||||
<div className={s.progressFill} style={{ width: `${progressPct}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={s.actions}>
|
|
||||||
<button
|
|
||||||
className={[s.libraryBtn, manga?.inLibrary ? s.libraryBtnActive : ""].join(" ").trim()}
|
|
||||||
onClick={toggleLibrary}
|
|
||||||
disabled={togglingLibrary || loadingManga}
|
|
||||||
>
|
|
||||||
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
|
|
||||||
{manga?.inLibrary ? "In Library" : "Add to Library"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{manga?.realUrl && (
|
|
||||||
<a href={manga.realUrl} target="_blank" rel="noreferrer" className={s.externalLink}>
|
|
||||||
<ArrowSquareOut size={13} weight="light" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Folder picker moved to chapter list header */}
|
|
||||||
|
|
||||||
{continueChapter && (
|
|
||||||
<button
|
|
||||||
className={s.readBtn}
|
|
||||||
onClick={() => openReader(continueChapter.chapter, sortedChapters)}
|
|
||||||
>
|
|
||||||
<Play size={12} weight="fill" />
|
|
||||||
{continueChapter.type === "continue"
|
|
||||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${
|
|
||||||
(continueChapter.chapter.lastPageRead ?? 0) > 0
|
|
||||||
? ` p.${continueChapter.chapter.lastPageRead}`
|
|
||||||
: ""
|
|
||||||
}`
|
|
||||||
: continueChapter.type === "reread"
|
|
||||||
? "Read again"
|
|
||||||
: "Start reading"
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className={s.chapterCount}>
|
|
||||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* ── Details (collapsible) ── */}
|
|
||||||
{!loadingManga && manga?.source && (
|
|
||||||
<div className={s.detailsSection}>
|
|
||||||
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
|
|
||||||
<span>Details</span>
|
|
||||||
<CaretDown size={11} weight="light" className={detailsOpen ? s.caretOpen : s.caretClosed} />
|
|
||||||
</button>
|
|
||||||
{detailsOpen && (
|
|
||||||
<div className={s.detailsBody}>
|
|
||||||
<div className={s.detailRow}>
|
|
||||||
<span className={s.detailKey}>Source</span>
|
|
||||||
<span className={s.detailVal}>{manga.source.displayName}</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.detailRow}>
|
|
||||||
<span className={s.detailKey}>Language</span>
|
|
||||||
<span className={s.detailVal}>{manga.source.name.match(/\(([^)]+)\)$/)?.[1] ?? "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.detailRow}>
|
|
||||||
<span className={s.detailKey}>Source ID</span>
|
|
||||||
<span className={[s.detailVal, s.detailMono].join(" ")}>{manga.source.id}</span>
|
|
||||||
</div>
|
|
||||||
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
|
|
||||||
<ArrowsClockwise size={12} weight="light" />
|
|
||||||
Switch source
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Delete all downloads */}
|
|
||||||
{downloadedCount > 0 && (
|
|
||||||
<button
|
|
||||||
className={s.deleteAllBtn}
|
|
||||||
onClick={deleteAllDownloads}
|
|
||||||
disabled={deletingAll}
|
|
||||||
>
|
|
||||||
<Trash size={12} weight="light" />
|
|
||||||
{deletingAll ? "Deleting…" : `Delete all downloads (${downloadedCount})`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Chapter list ── */}
|
|
||||||
<div className={s.listWrap}>
|
|
||||||
<div className={s.listHeader}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)" }}>
|
|
||||||
<button
|
|
||||||
className={s.sortBtn}
|
|
||||||
onClick={() => {
|
|
||||||
updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" });
|
|
||||||
setChapterPage(1);
|
|
||||||
}}
|
|
||||||
title={sortDir === "desc" ? "Newest first" : "Oldest first"}
|
|
||||||
>
|
|
||||||
{sortDir === "desc"
|
|
||||||
? <SortDescending size={14} weight="light" />
|
|
||||||
: <SortAscending size={14} weight="light" />
|
|
||||||
}
|
|
||||||
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={[s.viewToggleBtn, viewMode === "grid" ? s.viewToggleActive : ""].join(" ")}
|
|
||||||
onClick={() => setViewMode((v) => v === "list" ? "grid" : "list")}
|
|
||||||
title={viewMode === "list" ? "Switch to grid view" : "Switch to list view"}
|
|
||||||
>
|
|
||||||
{viewMode === "list"
|
|
||||||
? <SquaresFour size={14} weight="light" />
|
|
||||||
: <List size={14} weight="light" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.listHeaderRight}>
|
|
||||||
{/* Folder picker */}
|
|
||||||
{activeManga && <FolderPicker mangaId={activeManga.id} />}
|
|
||||||
|
|
||||||
{/* Jump to chapter */}
|
|
||||||
{chapters.length > 1 && (
|
|
||||||
<div className={s.jumpWrap}>
|
|
||||||
{!jumpOpen ? (
|
|
||||||
<button className={s.jumpToggle} onClick={() => { setJumpOpen(true); setJumpInput(""); }}>
|
|
||||||
Go to…
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className={s.jumpRow}>
|
|
||||||
<input
|
|
||||||
className={s.jumpInput}
|
|
||||||
type="text"
|
|
||||||
placeholder="Ch. #"
|
|
||||||
value={jumpInput}
|
|
||||||
autoFocus
|
|
||||||
onChange={(e) => setJumpInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") { setJumpOpen(false); return; }
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
const num = parseFloat(jumpInput);
|
|
||||||
if (!isNaN(num)) {
|
|
||||||
const target = sortedChapters.find((c) => c.chapterNumber === num)
|
|
||||||
?? sortedChapters.reduce((best, c) =>
|
|
||||||
Math.abs(c.chapterNumber - num) < Math.abs(best.chapterNumber - num) ? c : best
|
|
||||||
, sortedChapters[0]);
|
|
||||||
if (target) openReader(target, sortedChapters);
|
|
||||||
}
|
|
||||||
setJumpOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button className={s.jumpCancel} onClick={() => setJumpOpen(false)}>✕</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Download menu */}
|
|
||||||
{chapters.length > 0 && (
|
|
||||||
<div className={s.dlWrap}>
|
|
||||||
<button className={s.dlToggleBtn} onClick={() => setDlOpen((p) => !p)}>
|
|
||||||
<Download size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
{dlOpen && (
|
|
||||||
<div className={s.dlDropdown}>
|
|
||||||
{continueChapter && (
|
|
||||||
<button className={s.dlItem}
|
|
||||||
onClick={() => {
|
|
||||||
const from = sortedChapters.indexOf(continueChapter.chapter);
|
|
||||||
const ids = sortedChapters.slice(from).filter((c) => !c.isDownloaded).map((c) => c.id);
|
|
||||||
enqueueMultiple(ids);
|
|
||||||
setDlOpen(false);
|
|
||||||
}}>
|
|
||||||
<span>From current</span>
|
|
||||||
<span className={s.dlItemSub}>Ch.{continueChapter.chapter.chapterNumber} onwards</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className={s.dlItem}
|
|
||||||
onClick={() => {
|
|
||||||
const ids = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).map((c) => c.id);
|
|
||||||
enqueueMultiple(ids);
|
|
||||||
setDlOpen(false);
|
|
||||||
}}>
|
|
||||||
<span>Unread chapters</span>
|
|
||||||
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).length} remaining</span>
|
|
||||||
</button>
|
|
||||||
<button className={s.dlItem}
|
|
||||||
onClick={() => {
|
|
||||||
const ids = sortedChapters.filter((c) => !c.isDownloaded).map((c) => c.id);
|
|
||||||
enqueueMultiple(ids);
|
|
||||||
setDlOpen(false);
|
|
||||||
}}>
|
|
||||||
<span>Download all</span>
|
|
||||||
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span>
|
|
||||||
</button>
|
|
||||||
{downloadedCount > 0 && (
|
|
||||||
<>
|
|
||||||
<div style={{ height: 1, background: "var(--border-dim)", margin: "var(--sp-1) var(--sp-2)" }} />
|
|
||||||
<button className={[s.dlItem, s.dlItemDanger].join(" ")}
|
|
||||||
onClick={() => { deleteAllDownloads(); setDlOpen(false); }}
|
|
||||||
disabled={deletingAll}
|
|
||||||
>
|
|
||||||
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
|
||||||
<span className={s.dlItemSub}>{downloadedCount} downloaded</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className={s.pagination}>
|
|
||||||
<button
|
|
||||||
className={s.pageBtn}
|
|
||||||
onClick={() => setChapterPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={chapterPage === 1}
|
|
||||||
>←</button>
|
|
||||||
<span className={s.pageNum}>{chapterPage} / {totalPages}</span>
|
|
||||||
<button
|
|
||||||
className={s.pageBtn}
|
|
||||||
onClick={() => setChapterPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={chapterPage === totalPages}
|
|
||||||
>→</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={viewMode === "grid" ? s.grid : s.list}>
|
|
||||||
{loadingChapters && chapters.length === 0 ? (
|
|
||||||
viewMode === "grid" ? (
|
|
||||||
Array.from({ length: 24 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.gridCellSkeleton}>
|
|
||||||
<div className="skeleton" style={{ width: "60%", height: 10, borderRadius: 3 }} />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.rowSkeleton}>
|
|
||||||
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "55%", height: 12 }} />
|
|
||||||
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "25%", height: 11 }} />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
) : viewMode === "grid" ? (
|
|
||||||
sortedChapters.map((ch) => {
|
|
||||||
const idxInSorted = sortedChapters.indexOf(ch);
|
|
||||||
const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={ch.id}
|
|
||||||
className={[
|
|
||||||
s.gridCell,
|
|
||||||
ch.isRead ? s.gridCellRead : "",
|
|
||||||
inProgress ? s.gridCellInProgress : "",
|
|
||||||
ch.isBookmarked ? s.gridCellBookmarked : "",
|
|
||||||
].filter(Boolean).join(" ")}
|
|
||||||
onClick={() => openReader(ch, sortedChapters)}
|
|
||||||
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
|
|
||||||
title={ch.name}
|
|
||||||
>
|
|
||||||
<span className={s.gridCellNum}>
|
|
||||||
{ch.chapterNumber % 1 === 0
|
|
||||||
? ch.chapterNumber.toFixed(0)
|
|
||||||
: ch.chapterNumber.toString()}
|
|
||||||
</span>
|
|
||||||
{ch.isRead && <span className={s.gridCellDot} />}
|
|
||||||
{inProgress && <span className={s.gridCellProgress} style={{ width: `${Math.min(100, ((ch.lastPageRead ?? 0) / 1) * 100)}%` }} />}
|
|
||||||
{ch.isBookmarked && <span className={s.gridCellBookmarkDot} />}
|
|
||||||
{enqueueing.has(ch.id) && (
|
|
||||||
<span className={s.gridCellSpinner}>
|
|
||||||
<CircleNotch size={10} weight="light" className="anim-spin" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
pageChapters.map((ch) => {
|
|
||||||
const idxInSorted = sortedChapters.indexOf(ch);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={ch.id}
|
|
||||||
className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()}
|
|
||||||
onClick={() => openReader(ch, sortedChapters)}
|
|
||||||
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
|
|
||||||
>
|
|
||||||
<div className={s.chLeft}>
|
|
||||||
<span className={s.chName}>{ch.name}</span>
|
|
||||||
<div className={s.chMeta}>
|
|
||||||
{ch.scanlator && <span className={s.chMetaItem}>{ch.scanlator}</span>}
|
|
||||||
{ch.uploadDate && <span className={s.chMetaItem}>{formatDate(ch.uploadDate)}</span>}
|
|
||||||
{ch.lastPageRead != null && ch.lastPageRead > 0 && !ch.isRead && (
|
|
||||||
<span className={s.chMetaItem}>p.{ch.lastPageRead}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.chRight}>
|
|
||||||
{ch.isBookmarked && (
|
|
||||||
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
|
|
||||||
)}
|
|
||||||
{ch.isRead ? (
|
|
||||||
<CheckCircle size={14} weight="light" className={s.readIcon} />
|
|
||||||
) : ch.isDownloaded ? (
|
|
||||||
<BookOpen size={14} weight="light" className={s.downloadedIcon} />
|
|
||||||
) : enqueueing.has(ch.id) ? (
|
|
||||||
<CircleNotch size={14} weight="light" className={[s.enqueuingIcon, "anim-spin"].join(" ")} />
|
|
||||||
) : (
|
|
||||||
<button className={s.dlBtn} onClick={(e) => enqueue(ch, e)} title="Download">
|
|
||||||
<Download size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className={s.paginationBottom}>
|
|
||||||
<button
|
|
||||||
className={s.pageBtn}
|
|
||||||
onClick={() => setChapterPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={chapterPage === 1}
|
|
||||||
>← Prev</button>
|
|
||||||
<span className={s.pageNum}>{chapterPage} / {totalPages}</span>
|
|
||||||
<button
|
|
||||||
className={s.pageBtn}
|
|
||||||
onClick={() => setChapterPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={chapterPage === totalPages}
|
|
||||||
>Next →</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ctx && (
|
|
||||||
<ContextMenu
|
|
||||||
x={ctx.x}
|
|
||||||
y={ctx.y}
|
|
||||||
items={buildCtxItems(ctx.chapter, ctx.indexInSorted)}
|
|
||||||
onClose={() => setCtx(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{migrateOpen && manga && (
|
|
||||||
<MigrateModal
|
|
||||||
manga={manga}
|
|
||||||
currentChapters={chapters}
|
|
||||||
onClose={() => setMigrateOpen(false)}
|
|
||||||
onMigrated={(newManga: Manga) => {
|
|
||||||
setMigrateOpen(false);
|
|
||||||
setActiveManga(newManga);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import {
|
||||||
|
GET_ALL_TRACKER_RECORDS,
|
||||||
|
UPDATE_TRACK,
|
||||||
|
UNBIND_TRACK,
|
||||||
|
FETCH_TRACK,
|
||||||
|
} from "../../lib/queries";
|
||||||
|
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||||
|
import type { Tracker, TrackRecord } from "../../lib/types";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TrackerWithRecords extends Tracker {
|
||||||
|
trackRecords: { nodes: TrackRecord[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlatRecord extends TrackRecord {
|
||||||
|
tracker: Tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let trackers: TrackerWithRecords[] = $state([]);
|
||||||
|
let loading: boolean = $state(true);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
|
// Filter/view state
|
||||||
|
let activeTrackerId: number | "all" = $state("all");
|
||||||
|
let statusFilter: number | "all" = $state("all");
|
||||||
|
let searchQuery: string = $state("");
|
||||||
|
let sortBy: "title" | "status" | "score" | "progress" = $state("title");
|
||||||
|
|
||||||
|
// Mutation state
|
||||||
|
let updatingId: number | null = $state(null);
|
||||||
|
let syncingId: number | null = $state(null);
|
||||||
|
// Chapter editing: recordId → draft value
|
||||||
|
let editingChapter: number | null = $state(null);
|
||||||
|
let chapterDraft: number = $state(0);
|
||||||
|
|
||||||
|
// ── Load ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true; error = null;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
|
||||||
|
trackers = res.trackers.nodes;
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e?.message ?? "Failed to load tracking data";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { load(); });
|
||||||
|
|
||||||
|
// ── Derived data ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||||
|
|
||||||
|
const allRecords: FlatRecord[] = $derived(
|
||||||
|
loggedInTrackers.flatMap(t =>
|
||||||
|
t.trackRecords.nodes.map(r => ({
|
||||||
|
...r,
|
||||||
|
trackerId: r.trackerId ?? t.id, // fallback in case field is missing
|
||||||
|
tracker: t as Tracker,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCount = $derived(allRecords.length);
|
||||||
|
|
||||||
|
// Status options across active tracker
|
||||||
|
const statusOptions = $derived.by(() => {
|
||||||
|
if (activeTrackerId === "all") {
|
||||||
|
// Merge all statuses, dedupe by value+name
|
||||||
|
const seen = new Map<string, { value: number; name: string }>();
|
||||||
|
for (const t of loggedInTrackers) {
|
||||||
|
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
}
|
||||||
|
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
let list = activeTrackerId === "all"
|
||||||
|
? allRecords
|
||||||
|
: allRecords.filter(r => Number(r.trackerId) === Number(activeTrackerId));
|
||||||
|
|
||||||
|
if (statusFilter !== "all")
|
||||||
|
list = list.filter(r => Number(r.status) === Number(statusFilter));
|
||||||
|
|
||||||
|
if (searchQuery.trim())
|
||||||
|
list = list.filter(r =>
|
||||||
|
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
r.manga?.title?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
if (sortBy === "title") return a.title.localeCompare(b.title);
|
||||||
|
if (sortBy === "status") return a.status - b.status;
|
||||||
|
if (sortBy === "score") return parseFloat(b.displayScore ?? "0") - parseFloat(a.displayScore ?? "0");
|
||||||
|
if (sortBy === "progress") {
|
||||||
|
const ap = a.totalChapters > 0 ? a.lastChapterRead / a.totalChapters : 0;
|
||||||
|
const bp = b.totalChapters > 0 ? b.lastChapterRead / b.totalChapters : 0;
|
||||||
|
return bp - ap;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function updateStatus(record: FlatRecord, status: number) {
|
||||||
|
updatingId = record.id;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, status }
|
||||||
|
);
|
||||||
|
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
updatingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateScore(record: FlatRecord, scoreString: string) {
|
||||||
|
updatingId = record.id;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, scoreString }
|
||||||
|
);
|
||||||
|
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
updatingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRecord(record: FlatRecord) {
|
||||||
|
syncingId = record.id;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
FETCH_TRACK, { recordId: record.id }
|
||||||
|
);
|
||||||
|
patchRecord(record.trackerId, res.fetchTrack.trackRecord);
|
||||||
|
addToast({ kind: "success", title: "Synced from tracker" });
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
syncingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbind(record: FlatRecord) {
|
||||||
|
updatingId = record.id;
|
||||||
|
try {
|
||||||
|
await gql(UNBIND_TRACK, { recordId: record.id });
|
||||||
|
trackers = trackers.map(t =>
|
||||||
|
t.id !== record.trackerId ? t : {
|
||||||
|
...t,
|
||||||
|
trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== record.id) }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
addToast({ kind: "info", title: "Unlinked from " + record.tracker.name });
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
updatingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
|
||||||
|
trackers = trackers.map(t =>
|
||||||
|
t.id !== trackerId ? t : {
|
||||||
|
...t,
|
||||||
|
trackRecords: {
|
||||||
|
nodes: t.trackRecords.nodes.map(r => r.id === updated.id ? { ...r, ...updated } : r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManga(record: FlatRecord) {
|
||||||
|
if (!record.manga) return;
|
||||||
|
setActiveManga(record.manga as any);
|
||||||
|
setNavPage("library");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChapterEditor(record: FlatRecord) {
|
||||||
|
editingChapter = record.id;
|
||||||
|
chapterDraft = record.lastChapterRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelChapterEditor() {
|
||||||
|
editingChapter = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChapter(record: FlatRecord) {
|
||||||
|
const val = Math.max(0, chapterDraft);
|
||||||
|
editingChapter = null;
|
||||||
|
if (val === record.lastChapterRead) return;
|
||||||
|
updatingId = record.id;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }
|
||||||
|
);
|
||||||
|
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
updatingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-top">
|
||||||
|
<h1 class="heading">Tracking</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh all">
|
||||||
|
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tracker filter tabs -->
|
||||||
|
{#if !loading && loggedInTrackers.length > 0}
|
||||||
|
<div class="tracker-tabs">
|
||||||
|
<button
|
||||||
|
class="tracker-tab"
|
||||||
|
class:tab-active={activeTrackerId === "all"}
|
||||||
|
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
<span class="tab-count">{totalCount}</span>
|
||||||
|
</button>
|
||||||
|
{#each loggedInTrackers as t}
|
||||||
|
{@const count = t.trackRecords.nodes.length}
|
||||||
|
<button
|
||||||
|
class="tracker-tab"
|
||||||
|
class:tab-active={activeTrackerId === t.id}
|
||||||
|
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
|
||||||
|
>
|
||||||
|
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-tracker-icon" />
|
||||||
|
{t.name}
|
||||||
|
<span class="tab-count">{count}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter + sort bar -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<MagnifyingGlass size={12} weight="light" class="search-ico" />
|
||||||
|
<input
|
||||||
|
class="filter-search"
|
||||||
|
placeholder="Search titles…"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-right">
|
||||||
|
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||||
|
<select class="filter-select" bind:value={statusFilter}
|
||||||
|
onchange={(e) => {
|
||||||
|
const v = (e.target as HTMLSelectElement).value;
|
||||||
|
statusFilter = v === "all" ? "all" : parseInt(v);
|
||||||
|
}}>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
{#each statusOptions as s}
|
||||||
|
<option value={s.value}>{s.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select class="filter-select" bind:value={sortBy}>
|
||||||
|
<option value="title">Sort: Title</option>
|
||||||
|
<option value="status">Sort: Status</option>
|
||||||
|
<option value="score">Sort: Score</option>
|
||||||
|
<option value="progress">Sort: Progress</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Body ────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="page-body">
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="state-center">
|
||||||
|
<CircleNotch size={22} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
|
<span class="state-label">Loading tracking data…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if error}
|
||||||
|
<div class="state-center">
|
||||||
|
<p class="state-error">{error}</p>
|
||||||
|
<button class="retry-btn" onclick={load}>Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if loggedInTrackers.length === 0}
|
||||||
|
<div class="state-center">
|
||||||
|
<p class="state-text">No trackers connected.</p>
|
||||||
|
<p class="state-hint">Go to Settings → Tracking to log in to AniList, MAL, or others.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<div class="state-center">
|
||||||
|
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results match your filters." : "Nothing tracked yet."}</p>
|
||||||
|
{#if searchQuery || statusFilter !== "all"}
|
||||||
|
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="records-list">
|
||||||
|
{#each filtered as record (record.tracker.id + ":" + record.id)}
|
||||||
|
{@const tracker = record.tracker}
|
||||||
|
{@const isBusy = updatingId === record.id}
|
||||||
|
{@const isSyncing = syncingId === record.id}
|
||||||
|
{@const progress = record.totalChapters > 0
|
||||||
|
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<div class="record-card" class:record-busy={isBusy}>
|
||||||
|
|
||||||
|
<!-- Cover -->
|
||||||
|
<div class="record-cover-wrap" role="button" tabindex="0"
|
||||||
|
onclick={() => openManga(record)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||||
|
>
|
||||||
|
{#if record.manga?.thumbnailUrl}
|
||||||
|
<img src={thumbUrl(record.manga.thumbnailUrl)} alt={record.title} class="record-cover" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="record-cover record-cover-empty"></div>
|
||||||
|
{/if}
|
||||||
|
<!-- Tracker badge -->
|
||||||
|
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-badge" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="record-body">
|
||||||
|
<div class="record-top">
|
||||||
|
<div class="record-titles" role="button" tabindex="0"
|
||||||
|
onclick={() => openManga(record)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||||
|
>
|
||||||
|
<span class="record-title">{record.title}</span>
|
||||||
|
{#if record.manga?.title && record.manga.title !== record.title}
|
||||||
|
<span class="record-local-title">{record.manga.title}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="record-header-actions">
|
||||||
|
{#if activeTrackerId === "all"}
|
||||||
|
<span class="record-tracker-label">
|
||||||
|
<img src={thumbUrl(record.tracker.icon)} alt={record.tracker.name} class="record-tracker-label-icon" />
|
||||||
|
{record.tracker.name}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if isSyncing}
|
||||||
|
<CircleNotch size={12} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
|
{:else}
|
||||||
|
<button class="card-icon-btn" title="Sync from tracker" onclick={() => syncRecord(record)}>
|
||||||
|
<ArrowsClockwise size={12} weight="light" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if record.remoteUrl}
|
||||||
|
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-icon-btn" title="Open on {record.tracker.name}">
|
||||||
|
<ArrowSquareOut size={12} weight="light" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button class="card-icon-btn danger" title="Unlink" onclick={() => unbind(record)} disabled={isBusy}>
|
||||||
|
<X size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls row -->
|
||||||
|
<div class="record-controls">
|
||||||
|
<select
|
||||||
|
class="record-select"
|
||||||
|
value={record.status}
|
||||||
|
disabled={isBusy}
|
||||||
|
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
||||||
|
>
|
||||||
|
{#each (tracker.statuses ?? []) as s}
|
||||||
|
<option value={s.value}>{s.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
class="record-select record-select-score"
|
||||||
|
value={record.displayScore}
|
||||||
|
disabled={isBusy}
|
||||||
|
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
{#each (tracker.scores ?? []) as s}
|
||||||
|
<option value={s}>★ {s}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{#if record.private}
|
||||||
|
<span class="private-badge" title="Private"><Lock size={10} weight="fill" /></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress / Chapter editor -->
|
||||||
|
{#if editingChapter === record.id}
|
||||||
|
<div class="chapter-editor">
|
||||||
|
<div class="chapter-editor-top">
|
||||||
|
<span class="chapter-editor-label">Chapter read</span>
|
||||||
|
<div class="chapter-input-wrap">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="chapter-input"
|
||||||
|
min="0"
|
||||||
|
max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||||
|
step="0.5"
|
||||||
|
bind:value={chapterDraft}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") submitChapter(record);
|
||||||
|
if (e.key === "Escape") cancelChapterEditor();
|
||||||
|
}}
|
||||||
|
use:focusEl
|
||||||
|
/>
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
<span class="chapter-total">/ {record.totalChapters}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="chapter-slider"
|
||||||
|
min="0"
|
||||||
|
max={record.totalChapters}
|
||||||
|
step="1"
|
||||||
|
bind:value={chapterDraft}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class="chapter-editor-actions">
|
||||||
|
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||||
|
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if progress !== null}
|
||||||
|
<div class="record-progress clickable" role="button" tabindex="0"
|
||||||
|
onclick={() => openChapterEditor(record)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" style="width:{progress}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
|
||||||
|
<span class="progress-edit-hint">✎</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="record-progress clickable no-total" role="button" tabindex="0"
|
||||||
|
onclick={() => openChapterEditor(record)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||||
|
title="Click to set chapter"
|
||||||
|
>
|
||||||
|
<span class="progress-label">
|
||||||
|
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"}
|
||||||
|
</span>
|
||||||
|
<span class="progress-edit-hint">✎</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||||
|
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); }
|
||||||
|
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); }
|
||||||
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
/* ── Tracker tabs ───────────────────────────────────────────────────────── */
|
||||||
|
.tracker-tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-4); overflow-x: auto; scrollbar-width: none; }
|
||||||
|
.tracker-tabs::-webkit-scrollbar { display: none; }
|
||||||
|
.tracker-tab { display: flex; align-items: center; gap: var(--sp-2); padding: 4px 10px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-radius: var(--radius-md); cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.tracker-tab:hover { color: var(--text-muted); }
|
||||||
|
.tab-active { background: var(--accent-muted); color: var(--accent-fg) !important; border: 1px solid var(--accent-dim); }
|
||||||
|
.tab-tracker-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
|
||||||
|
.tab-count { font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 18px; text-align: center; }
|
||||||
|
|
||||||
|
/* ── Filter bar ─────────────────────────────────────────────────────────── */
|
||||||
|
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); }
|
||||||
|
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; }
|
||||||
|
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||||
|
.filter-search::placeholder { color: var(--text-faint); }
|
||||||
|
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||||
|
.filter-select {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 28px 5px 10px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||||
|
color: var(--text-muted); outline: none; cursor: pointer;
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat; background-position: right 8px center;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.filter-select:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||||
|
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||||
|
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
||||||
|
|
||||||
|
/* ── States ─────────────────────────────────────────────────────────────── */
|
||||||
|
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; }
|
||||||
|
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
||||||
|
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
|
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
/* ── Records list ───────────────────────────────────────────────────────── */
|
||||||
|
.records-list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.record-card {
|
||||||
|
display: flex; align-items: flex-start; gap: var(--sp-4);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
transition: border-color var(--t-base), opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.record-card:hover { border-color: var(--border-strong); }
|
||||||
|
.record-busy { opacity: 0.5; pointer-events: none; }
|
||||||
|
|
||||||
|
/* Cover */
|
||||||
|
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; }
|
||||||
|
.record-cover { width: 48px; height: 68px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; }
|
||||||
|
.record-cover-empty { background: var(--bg-overlay); }
|
||||||
|
.record-cover-wrap:hover .record-cover { opacity: 0.8; }
|
||||||
|
.record-tracker-badge { position: absolute; bottom: -4px; right: -4px; width: 16px; height: 16px; border-radius: 3px; border: 1px solid var(--bg-raised); object-fit: contain; background: var(--bg-raised); }
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.record-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); }
|
||||||
|
.record-titles { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; cursor: pointer; }
|
||||||
|
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
|
||||||
|
.record-titles:hover .record-title { color: var(--accent-fg); }
|
||||||
|
.record-local-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.record-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
||||||
|
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
|
||||||
|
.record-tracker-label-icon { width: 12px; height: 12px; border-radius: 2px; object-fit: contain; }
|
||||||
|
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
.card-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
.record-select {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 3px 26px 3px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong); background: var(--bg-overlay);
|
||||||
|
color: var(--text-secondary); outline: none; cursor: pointer;
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat; background-position: right 7px center;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); }
|
||||||
|
.record-select:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
.record-select-score { max-width: 90px; }
|
||||||
|
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.record-progress { display: flex; align-items: center; gap: var(--sp-3); }
|
||||||
|
.progress-track { flex: 1; height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; max-width: 160px; }
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||||
|
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||||
|
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); }
|
||||||
|
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
||||||
|
.record-progress.clickable:hover .progress-label { color: var(--text-muted); }
|
||||||
|
.progress-edit-hint { font-size: 10px; color: var(--text-faint); opacity: 0; transition: opacity var(--t-fast); }
|
||||||
|
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
|
||||||
|
|
||||||
|
/* Chapter editor */
|
||||||
|
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); }
|
||||||
|
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||||
|
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.chapter-input { width: 72px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
|
||||||
|
.chapter-input:focus { border-color: var(--accent); }
|
||||||
|
.chapter-input::-webkit-outer-spin-button,
|
||||||
|
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; }
|
||||||
|
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
||||||
|
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 14px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||||
|
.chapter-save-btn:hover { filter: brightness(1.15); }
|
||||||
|
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; 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); }
|
||||||
|
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script module>
|
||||||
|
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||||
|
</script>
|
||||||