Compare commits
152 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c573c54318 | |||
| ff5fcc4fc0 | |||
| 64f63ceaa2 | |||
| 6d835914ef | |||
| 10f5936dbd | |||
| 5ddbfdbd6d | |||
| 0ff148f720 | |||
| d98ca76036 | |||
| 35650481b0 | |||
| 8b16537c35 | |||
| 96639d2152 | |||
| 1c135a79ca | |||
| 6c11a9d53e | |||
| 5a2f88b806 | |||
| 75430305e6 | |||
| ea76b5fc26 | |||
| d5d9ff8b6e | |||
| 7c9182eb4b | |||
| 4d6ebe8804 | |||
| 49562c3f76 | |||
| 4a299f60ac | |||
| de397f2462 | |||
| af29cffdff | |||
| f840ae6413 | |||
| 6b8d4fc05f | |||
| 15079f7755 | |||
| 1a08d2415f | |||
| 7917491389 | |||
| 0b6e9fbbbb | |||
| 023b23288b | |||
| 67a9f0b944 | |||
| 56392e2427 | |||
| 843e205072 | |||
| ee708d85d0 | |||
| 8005c82654 | |||
| d989b2d67e | |||
| 6446a19b2d | |||
| 5cd96abc0c | |||
| db44afc4dc | |||
| 4248e344ab | |||
| 8941bfef10 | |||
| 11cd6ff870 | |||
| 15adb02be3 | |||
| 51bb6cdab9 | |||
| 454a674ada | |||
| f146de5c02 | |||
| 04f680c3bb | |||
| f49f7e7ac1 | |||
| a62512bf42 | |||
| d91ed2e6d1 | |||
| 61e3c4ee2f | |||
| 9151820843 | |||
| 63c890dadf | |||
| 51a33679d5 | |||
| 82f8a9a36b | |||
| 4decce9a7f | |||
| a69d5eacc5 | |||
| 4959722759 | |||
| 35ba0171c7 | |||
| d26fa50e76 | |||
| fd9d216325 | |||
| 581eb2adb0 | |||
| 8aa2dc2547 | |||
| 0a11fe3982 | |||
| f6786def87 | |||
| 262027d9f9 | |||
| d407359973 | |||
| a77572a8d4 | |||
| 32d2fffdc5 | |||
| e850cbac1e | |||
| eebd1b6446 | |||
| 5ed072211b | |||
| 62e41e5f07 | |||
| 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 |
@@ -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 ---
|
||||
build-dir/
|
||||
repo/
|
||||
dist/
|
||||
packaging/frontend-dist.tar.gz
|
||||
*.flatpak
|
||||
.flatpak-builder/
|
||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=moku
|
||||
pkgver=0.2.0
|
||||
pkgver=0.5.0
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
@@ -22,7 +22,7 @@ source=(
|
||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
||||
)
|
||||
sha256sums=('dfd110ae4f11711ce979020ae65b08ab2d0bd51ecc1ba877ba1780ba037357a4'
|
||||
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||
|
||||
@@ -33,14 +33,8 @@ prepare() {
|
||||
|
||||
build() {
|
||||
cd "Moku-$pkgver"
|
||||
|
||||
# Build frontend
|
||||
pnpm build
|
||||
|
||||
# Repack dist for Tauri
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
|
||||
# Build Tauri binary
|
||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||
--release \
|
||||
--manifest-path src-tauri/Cargo.toml
|
||||
@@ -49,19 +43,15 @@ build() {
|
||||
package() {
|
||||
cd "Moku-$pkgver"
|
||||
|
||||
# Moku binary
|
||||
install -Dm755 src-tauri/target/release/moku \
|
||||
"$pkgdir/usr/bin/moku"
|
||||
|
||||
# Bundled JRE
|
||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
||||
|
||||
# Suwayomi server jar
|
||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||
|
||||
# tachidesk-server wrapper script
|
||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
||||
server.ip = "127.0.0.1"
|
||||
@@ -109,17 +99,16 @@ exec /usr/lib/moku/jre/bin/java \
|
||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||
EOF
|
||||
|
||||
# Desktop entry and icons
|
||||
install -Dm644 packaging/dev.moku.app.desktop \
|
||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \
|
||||
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop"
|
||||
install -Dm644 src-tauri/icons/32x32.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.app.png"
|
||||
install -Dm644 src-tauri/icons/128x128.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.app.png"
|
||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png"
|
||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
|
||||
@@ -1,137 +1,145 @@
|
||||
<div align="center">
|
||||
<img src="src/assets/rounded-logo.png" width="96" />
|
||||
<h1>Moku</h1>
|
||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
|
||||
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||
[](./LICENSE)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
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-TagSearch.png" width="49%" alt="TagSearch" />
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Reader
|
||||
- **Single**, **double-page**, and **longstrip** reading modes
|
||||
- **Infinite longstrip** — when Auto mode is enabled, the next chapter's pages are appended directly into the scroll without any re-render or gap; the entire series flows as one seamless ribbon
|
||||
- Fit modes: fit width, fit height, fit screen, and 1:1 original
|
||||
- Per-series zoom control via Ctrl+scroll or a slider popover
|
||||
- RTL / LTR reading direction toggle
|
||||
- Configurable page gaps
|
||||
- Full keyboard navigation with rebindable keybinds
|
||||
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
|
||||
- Chapter-relative page counter that updates live as you scroll through the infinite strip
|
||||
- Auto-mark chapters as read when the last page is reached
|
||||
|
||||
### Library
|
||||
- Grid view of your entire manga collection with lazy-loaded cover art
|
||||
- Filter tabs: **Saved**, **Downloaded**, and **All**
|
||||
- Genre tag filter chips — multi-select to narrow by any combination of tags
|
||||
- In-line search
|
||||
- Context menu: open, add/remove from library
|
||||
|
||||
### Series Detail
|
||||
- Cover, author, artist, status badge, genres, and synopsis
|
||||
- Read progress bar with percentage
|
||||
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
|
||||
- Chapter list with scanlator, upload date, and in-progress page indicator
|
||||
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
|
||||
- Sort by newest or oldest first
|
||||
- Jump-to-chapter input
|
||||
- Bulk download menu: from current chapter, unread only, or all
|
||||
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
|
||||
- Collapsible source details panel with source ID, language, and source migration
|
||||
|
||||
### Search
|
||||
- Cross-source search running up to 3 concurrent requests
|
||||
- Language filter bar (preferred language default, per-language, or all)
|
||||
- Results grouped by source with skeleton loading states
|
||||
|
||||
### Sources & Extensions
|
||||
- Browse and search installed sources, grouped by extension with per-language expansion
|
||||
- Extension manager: install, update, remove, and install from external APK URL
|
||||
- Repo refresh with update count badge
|
||||
|
||||
### Downloads
|
||||
- Download queue with live progress
|
||||
|
||||
### History
|
||||
- Reading history grouped by day with relative timestamps
|
||||
- Per-entry thumbnail, chapter name, and last-read page
|
||||
- Full-text search across titles and chapter names
|
||||
- One-click clear
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
||||
|
||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, A–Z, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
|
||||
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
||||
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
||||
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||
- **Auto-updates** — in-app update checker with silent background notifications
|
||||
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
**Nix (recommended)**
|
||||
### Flatpak (Linux, recommended)
|
||||
|
||||
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||
|
||||
```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:
|
||||
|
||||
```nix
|
||||
inputs.moku.url = "github:Youwes09/moku";
|
||||
inputs.moku.url = "github:Youwes09/Moku";
|
||||
```
|
||||
|
||||
**From source**
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Youwes09/moku
|
||||
cd moku
|
||||
nix build
|
||||
./result/bin/moku
|
||||
```
|
||||
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||
|
||||
### macOS
|
||||
|
||||
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||
|
||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||
> ```bash
|
||||
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
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
|
||||
|
||||
**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
|
||||
nix develop
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
|
||||
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
|
||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
Questions, feedback, or just want to hang out — join the Discord.
|
||||
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||
|
||||
@@ -1,89 +1,45 @@
|
||||
Todo:
|
||||
3. Explore Manga Upscaler & Other Image Processing
|
||||
4. Font Weird on Flatpak, Investigate and Fix
|
||||
5. Investigate "egl:failed to create dri2 screen"
|
||||
Major Revisions:
|
||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||
|
||||
Bugs:
|
||||
Minor Revisions:
|
||||
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
||||
|
||||
-
|
||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
||||
- Add Back after Search & Clear on Search
|
||||
- Add as Package in Nix Flake & Check Later
|
||||
- GenreDrill & GenreFilter pages do not populate completely.
|
||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
||||
- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy
|
||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||
- Adjustment in Settings for Theme Editor:
|
||||
- Patch Color-Picker to Work Properly
|
||||
- Integrate Download Directory Changes (Settings)
|
||||
|
||||
|
||||
- Fix Mangafire Main Dispatcher Issue
|
||||
|
||||
Priority Bugs:
|
||||
- Fix Library Build not Updating
|
||||
- Loading Buffer for Pictures (Due to Auth Lag)
|
||||
|
||||
|
||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
||||
|
||||
- Clean up Migrate Model to be more initutive
|
||||
|
||||
Features:
|
||||
- Add PDF Textbook Support
|
||||
- Major revision to disable entire manga-subsection and use as
|
||||
solely as a reader/document launcher.
|
||||
- Multiple Tag Filters + Mor Tags, Types, Etc
|
||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
||||
- Properly Kill Tachidesk-Server
|
||||
- Migration Features
|
||||
- Multi-Page Long Screenshot
|
||||
-
|
||||
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?)
|
||||
|
||||
|
||||
Big Revisions:
|
||||
0. Expand into fully-fledged reader, with modular manga support
|
||||
1. Anime & Novel Support
|
||||
2. Tracker Support
|
||||
3. Cloudflare Bypass Enable Support
|
||||
4. macOS Support (feasible)
|
||||
In-Progress:
|
||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||
|
||||
- Working on 3D Display Cards
|
||||
- Chapter refresh Notification Looks bad (Series Detail)
|
||||
|
||||
- Fix Discover Workout
|
||||
- Fix CSS on Saved State for Search
|
||||
- Fix State & Cache names (Mapped to Discover hence needs Renaming)
|
||||
- Completely Remove Discover
|
||||
|
||||
- Add Small QOL Animations where Appropriate
|
||||
|
||||
|
||||
|
||||
Testing:
|
||||
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
||||
5. Lock reader on valid chapters to avoid bugs, etc.
|
||||
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
||||
- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand
|
||||
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
||||
20. Expand History (Total Time Read, etc)
|
||||
12. Delete all Downloads should also cancel all download queues
|
||||
13. Cancel Download along with Queue & Download Timeout Feature
|
||||
|
||||
|
||||
Completed:
|
||||
8. Fix Polling on Download Manager (Instantanous Response)
|
||||
19. Debounce Time on Reader to improve lag (Toggle Setting)
|
||||
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
|
||||
17. Change Library Text change to "No manga saved to library, browse sources to add some."
|
||||
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
|
||||
7. Fix Scaling (100 = 125% and so forth)
|
||||
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
|
||||
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
|
||||
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
|
||||
11. Reader & UI needs download and other Notifications
|
||||
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
|
||||
- Add Refresh Details on Series Details.
|
||||
- Patch GenreDrill & Integrate into Explore Folder
|
||||
18. Disable NSFW Extensions option in settings
|
||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
||||
- Remove Series Detail Mark Read & Unread
|
||||
|
||||
|
||||
|
||||
|
||||
Important Commands:
|
||||
cd ~/Projects/Manga/Moku
|
||||
pnpm build
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
||||
|
||||
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
||||
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
||||
3. flatpak build-bundle repo moku.flatpak dev.moku.app
|
||||
@@ -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"
|
||||
@@ -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.5 MiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 947 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 5.0 MiB |
|
After Width: | Height: | Size: 940 KiB |
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1771438068,
|
||||
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=",
|
||||
"lastModified": 1773857772,
|
||||
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597",
|
||||
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -15,32 +15,16 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769996383,
|
||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -49,53 +33,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-appimage": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1757920913,
|
||||
"narHash": "sha256-jd0QwCVz4O1sHHkeaZILD/7D6oyalceEJ4EFnWCgm0k=",
|
||||
"owner": "ralismark",
|
||||
"repo": "nix-appimage",
|
||||
"rev": "7946addbc0d97e358a6d7aefe5e82310f0fe6b18",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ralismark",
|
||||
"repo": "nix-appimage",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771369470,
|
||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||
"lastModified": 1773821835,
|
||||
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -107,11 +51,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1769909678,
|
||||
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
||||
"lastModified": 1772328832,
|
||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -124,7 +68,6 @@
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-parts": "flake-parts",
|
||||
"nix-appimage": "nix-appimage",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
@@ -136,11 +79,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771556776,
|
||||
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
|
||||
"lastModified": 1773975983,
|
||||
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
|
||||
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -148,21 +91,6 @@
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -2,43 +2,34 @@
|
||||
description = "Moku — manga reader frontend for Suwayomi";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
crane.url = "github:ipetkov/crane";
|
||||
crane.url = "github:ipetkov/crane";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nix-appimage = {
|
||||
url = "github:ralismark/nix-appimage";
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ flake-parts, crane, rust-overlay, nix-appimage, ... }:
|
||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
|
||||
perSystem =
|
||||
{ system, pkgs, lib, ... }:
|
||||
perSystem = { system, lib, ... }:
|
||||
let
|
||||
pkgs' = import inputs.nixpkgs {
|
||||
version = "0.8.0";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs'.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rust-analyzer"
|
||||
];
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" "rust-analyzer" ];
|
||||
};
|
||||
|
||||
craneLib = (crane.mkLib pkgs').overrideToolchain rustToolchain;
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
|
||||
runtimeLibs = with pkgs; [
|
||||
webkitgtk_4_1
|
||||
@@ -65,56 +56,44 @@
|
||||
|| base == "package.json"
|
||||
|| base == "pnpm-lock.yaml"
|
||||
|| base == "tsconfig.json"
|
||||
|| base == "tsconfig.node.json"
|
||||
|| base == "vite.config.ts"
|
||||
|| base == "postcss.config.js"
|
||||
|| base == "postcss.config.cjs"
|
||||
|| base == "tailwind.config.js"
|
||||
|| base == "tailwind.config.ts";
|
||||
|| base == "vite.config.ts";
|
||||
};
|
||||
|
||||
frontend = pkgs.stdenv.mkDerivation {
|
||||
pname = "moku-frontend";
|
||||
version = "0.1.0";
|
||||
src = frontendSrc;
|
||||
pname = "moku-frontend";
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
nodejs_22
|
||||
pnpm
|
||||
pnpmConfigHook
|
||||
];
|
||||
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
pname = "moku-frontend";
|
||||
version = "0.1.0";
|
||||
src = frontendSrc;
|
||||
pname = "moku-frontend";
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
||||
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
buildPhase = "pnpm build";
|
||||
installPhase = "cp -r dist $out";
|
||||
};
|
||||
|
||||
cargoSrc = lib.cleanSourceWith {
|
||||
src = ./src-tauri;
|
||||
src = ./src-tauri;
|
||||
filter = path: type:
|
||||
(craneLib.filterCargoSources path type)
|
||||
|| (lib.hasInfix "/icons/" path)
|
||||
|| (lib.hasInfix "/icons/" path)
|
||||
|| (lib.hasInfix "/capabilities/" path)
|
||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
||||
};
|
||||
|
||||
commonArgs = {
|
||||
src = cargoSrc;
|
||||
cargoToml = ./src-tauri/Cargo.toml;
|
||||
cargoLock = ./src-tauri/Cargo.lock;
|
||||
src = cargoSrc;
|
||||
cargoToml = ./src-tauri/Cargo.toml;
|
||||
cargoLock = ./src-tauri/Cargo.lock;
|
||||
strictDeps = true;
|
||||
buildInputs = runtimeLibs;
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
wrapGAppsHook3
|
||||
];
|
||||
buildInputs = runtimeLibs;
|
||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
||||
preBuild = ''
|
||||
cp -r ${frontend} ../dist
|
||||
'';
|
||||
@@ -126,6 +105,36 @@
|
||||
inherit cargoArtifacts;
|
||||
meta.mainProgram = "moku";
|
||||
postInstall = ''
|
||||
mkdir -p "$out/share/applications"
|
||||
cat > "$out/share/applications/moku.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Moku
|
||||
Comment=Manga reader frontend for Suwayomi
|
||||
Exec=$out/bin/moku
|
||||
Icon=moku
|
||||
Terminal=false
|
||||
Categories=Graphics;Viewer;
|
||||
Keywords=manga;comic;reader;suwayomi;
|
||||
StartupWMClass=moku
|
||||
EOF
|
||||
|
||||
for size in 32x32 128x128 256x256 512x512; do
|
||||
src="icons/$size.png"
|
||||
[ -f "$src" ] && install -Dm644 "$src" \
|
||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||
done
|
||||
|
||||
for size in 128x128 256x256; do
|
||||
src="icons/''${size}@2x.png"
|
||||
[ -f "$src" ] && install -Dm644 "$src" \
|
||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||
done
|
||||
|
||||
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
||||
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||
|
||||
wrapProgram $out/bin/moku \
|
||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||
pkgs.gsettings-desktop-schemas
|
||||
@@ -134,16 +143,150 @@
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
||||
--set GDK_BACKEND wayland \
|
||||
--set WEBKIT_FORCE_SANDBOX 0
|
||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||
'';
|
||||
});
|
||||
|
||||
bumpScript = pkgs.writeShellApplication {
|
||||
name = "moku-bump";
|
||||
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ];
|
||||
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"
|
||||
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||
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/io.github.Youwes09.Moku.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" io.github.Youwes09.Moku
|
||||
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)"
|
||||
'';
|
||||
};
|
||||
|
||||
tunnelScript = pkgs.writeShellApplication {
|
||||
name = "moku-tunnel";
|
||||
runtimeInputs = with pkgs; [ cloudflared ];
|
||||
text = ''
|
||||
PORT="''${1:-4567}"
|
||||
cloudflared tunnel --url "http://localhost:$PORT"
|
||||
'';
|
||||
};
|
||||
|
||||
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"; };
|
||||
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
|
||||
};
|
||||
|
||||
packages = {
|
||||
inherit moku frontend;
|
||||
default = moku;
|
||||
appimage = nix-appimage.bundlers."${system}".default moku;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
@@ -155,29 +298,21 @@
|
||||
nodejs_22
|
||||
pnpm
|
||||
suwayomi-server
|
||||
cloudflared
|
||||
xdg-utils
|
||||
];
|
||||
shellHook = ''
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
export NO_STRIP=true
|
||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
||||
|
||||
if [ ! -e /usr/bin/xdg-open ]; then
|
||||
sudo ln -sf ${pkgs.xdg-utils}/bin/xdg-open /usr/bin/xdg-open
|
||||
fi
|
||||
|
||||
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage"
|
||||
LINUXDEPLOY_REAL="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage.real"
|
||||
if [ -f "$LINUXDEPLOY" ] && [ ! -f "$LINUXDEPLOY_REAL" ]; then
|
||||
mv "$LINUXDEPLOY" "$LINUXDEPLOY_REAL"
|
||||
printf '#!/bin/sh\nexec ${pkgs.appimage-run}/bin/appimage-run "%s" "$@"\n' "$LINUXDEPLOY_REAL" > "$LINUXDEPLOY"
|
||||
chmod +x "$LINUXDEPLOY"
|
||||
echo "linuxdeploy wrapped with appimage-run"
|
||||
fi
|
||||
|
||||
echo "Moku dev shell"
|
||||
echo " pnpm install && pnpm tauri:dev"
|
||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||
echo ""
|
||||
echo "Release:"
|
||||
echo " nix run .#bump -- <ver> bump versions only"
|
||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
||||
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Moku</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
app-id: dev.moku.app
|
||||
app-id: io.github.Youwes09.Moku
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '48'
|
||||
sdk: org.gnome.Sdk
|
||||
@@ -9,16 +9,22 @@ separate-locales: false
|
||||
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=x11
|
||||
- --socket=fallback-x11
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
- --share=network
|
||||
- --socket=session-bus
|
||||
- --socket=system-bus
|
||||
- --filesystem=home
|
||||
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --talk-name=org.freedesktop.portal.Desktop
|
||||
- --talk-name=org.freedesktop.portal.FileTransfer
|
||||
|
||||
- --talk-name=org.kde.StatusNotifierWatcher
|
||||
- --talk-name=com.canonical.AppMenu.Registrar
|
||||
- --talk-name=com.canonical.indicator.application
|
||||
|
||||
- --filesystem=xdg-run/discord-ipc-0:ro
|
||||
- --filesystem=xdg-data/moku:create
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-download
|
||||
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/rust-stable/bin
|
||||
@@ -33,13 +39,10 @@ modules:
|
||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
|
||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
|
||||
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
|
||||
dest-filename: jdk.tar.gz
|
||||
|
||||
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
|
||||
# exits just that thread instead of killing the whole JVM. Official Suwayomi
|
||||
# fix for headless environments. Source inlined to avoid upstream drift.
|
||||
- name: catch-abort
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
@@ -120,7 +123,6 @@ modules:
|
||||
fi
|
||||
|
||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
||||
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
|
||||
sed -i \
|
||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||
@@ -138,8 +140,6 @@ modules:
|
||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||
|
||||
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
|
||||
# that thread instead of crashing the whole JVM process.
|
||||
export LD_PRELOAD="/app/lib/catch_abort.so"
|
||||
|
||||
exec /app/jre/bin/java \
|
||||
@@ -171,17 +171,19 @@ modules:
|
||||
- tar -xzf frontend-dist.tar.gz
|
||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
|
||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
|
||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
|
||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
|
||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
||||
- install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop
|
||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png
|
||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.png
|
||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.png
|
||||
- install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml
|
||||
sources:
|
||||
- type: dir
|
||||
path: .
|
||||
- type: git
|
||||
url: https://github.com/Youwes09/Moku.git
|
||||
tag: v0.8.0
|
||||
commit: ff5fcc4fc0dd97e187fac15480406993bc4231da
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a
|
||||
sha256: f21034da4b8da42d8084978b60e429162aabb28808fa019ffb786e877c4ae95b
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
@@ -1,41 +1,31 @@
|
||||
{
|
||||
"name": "moku",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"tauri:build": "tauri build"
|
||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"@tauri-apps/plugin-http": "^2.5.8",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"zustand": "^5.0.0"
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||
"tauri-plugin-drpc": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^3.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>dev.moku.app</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<name>Moku</name>
|
||||
<summary>Manga reader powered by Suwayomi</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||
providing a clean native interface for browsing, reading, and managing your
|
||||
manga library across hundreds of sources.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/shozikan/Moku</url>
|
||||
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
|
||||
|
||||
<provides>
|
||||
<binary>moku</binary>
|
||||
</provides>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="0.1.0" date="2025-01-01">
|
||||
<description>
|
||||
<p>Initial release.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -2,7 +2,7 @@
|
||||
Name=Moku
|
||||
Comment=Manga reader powered by Suwayomi
|
||||
Exec=moku
|
||||
Icon=dev.moku.app
|
||||
Icon=io.github.Youwes09.Moku
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Graphics;Viewer;
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>io.github.Youwes09.Moku</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<name>Moku</name>
|
||||
<summary>Manga reader powered by Suwayomi</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||
providing a clean native interface for browsing, reading, and managing your
|
||||
manga library across hundreds of sources.
|
||||
</p>
|
||||
<p>
|
||||
Features include library management, chapter tracking, extension support,
|
||||
reading history, notifications, and Discord Rich Presence integration.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/Youwes09/Moku</url>
|
||||
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||
<caption>Home screen showing your manga library</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||
<caption>Built-in manga reader</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||
<caption>Discover new manga across hundreds of sources</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||
<caption>Download manager</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||
<caption>Settings</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<provides>
|
||||
<binary>moku</binary>
|
||||
</provides>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="0.8.0" date="2025-04-01">
|
||||
<description>
|
||||
<p>Latest release with improved stability and UI refinements.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.4.0" date="2025-03-22">
|
||||
<description>
|
||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.2.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "moku_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "moku"
|
||||
@@ -15,17 +15,26 @@ path = "src/main.rs"
|
||||
tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
walkdir = "2"
|
||||
nix = { version = "0.29", features = ["fs"] }
|
||||
dirs = "5"
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
walkdir = "2"
|
||||
sysinfo = "0.32"
|
||||
dirs = "5"
|
||||
urlencoding = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
@@ -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,43 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Allow launching tachidesk-server",
|
||||
"description": "Default permissions for Moku",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
{
|
||||
"identifier": "shell:allow-spawn",
|
||||
"allow": [
|
||||
{
|
||||
"name": "tachidesk-server",
|
||||
"cmd": "tachidesk-server"
|
||||
}
|
||||
]
|
||||
}
|
||||
"shell:allow-kill",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-execute",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-is-minimized",
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-outer-size",
|
||||
"core:window:allow-inner-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-scale-factor",
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download-and-install",
|
||||
"process:default",
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
"discord-rpc:default",
|
||||
"discord-rpc:allow-connect",
|
||||
"discord-rpc:allow-disconnect",
|
||||
"discord-rpc:allow-set-activity",
|
||||
"discord-rpc:allow-clear-activity",
|
||||
"discord-rpc:allow-is-running"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "http-scope",
|
||||
"description": "HTTP fetch scope",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "http://*:*/*" },
|
||||
{ "url": "https://*:*/*" },
|
||||
{ "url": "http://*/*" },
|
||||
{ "url": "https://*/*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
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::sync::Mutex;
|
||||
use nix::sys::statvfs::statvfs;
|
||||
use std::io::Write;
|
||||
use sysinfo::Disks;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, WindowEvent};
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -16,18 +19,46 @@ pub struct StorageInfo {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "kind", content = "message")]
|
||||
pub enum SpawnError {
|
||||
NotConfigured(String),
|
||||
SpawnFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ReleaseInfo {
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub published_at: String,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct UpdateProgress {
|
||||
downloaded: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
||||
let s = path.to_string_lossy();
|
||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(stripped)
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||
if !downloads_path.trim().is_empty() {
|
||||
return PathBuf::from(downloads_path);
|
||||
}
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/"))
|
||||
.join(".local/share")
|
||||
});
|
||||
base.join("Tachidesk/downloads")
|
||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
||||
base.join("Tachidesk").join("downloads")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -46,68 +77,429 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||
0
|
||||
};
|
||||
|
||||
let stat_path = if path.exists() { path.clone() } else {
|
||||
let stat_path = if path.exists() {
|
||||
path.clone()
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
};
|
||||
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
||||
|
||||
let frsize = vfs.fragment_size() as u64;
|
||||
let total_bytes = vfs.blocks() * frsize;
|
||||
let free_bytes = vfs.blocks_available() * frsize;
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let disk = disks
|
||||
.iter()
|
||||
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||
|
||||
Ok(StorageInfo {
|
||||
manga_bytes,
|
||||
total_bytes,
|
||||
free_bytes,
|
||||
total_bytes: disk.total_space(),
|
||||
free_bytes: disk.available_space(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the true OS-level scale factor for the main window.
|
||||
/// This reads directly from the underlying winit window handle, bypassing
|
||||
/// whatever value WebKitGTK happens to report to JS via window.devicePixelRatio.
|
||||
/// This is the only reliable way to get the correct DPR in all launch
|
||||
/// environments — tauri dev, nix run, flatpak, etc.
|
||||
#[tauri::command]
|
||||
fn get_scale_factor(window: tauri::Window) -> f64 {
|
||||
fn get_default_downloads_path() -> String {
|
||||
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_path_exists(path: String) -> bool {
|
||||
std::path::Path::new(path.trim()).is_dir()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_directory(path: String) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
use std::fs;
|
||||
|
||||
let src_path = std::path::PathBuf::from(src.trim());
|
||||
let dst_path = std::path::PathBuf::from(dst.trim());
|
||||
|
||||
if !src_path.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let total: u64 = WalkDir::new(&src_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.count() as u64;
|
||||
|
||||
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
|
||||
|
||||
let mut done: u64 = 0;
|
||||
|
||||
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
|
||||
let target = dst_path.join(rel);
|
||||
|
||||
if entry.file_type().is_dir() {
|
||||
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||
done += 1;
|
||||
let _ = app.emit("migrate_progress", serde_json::json!({
|
||||
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||
window.scale_factor().unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||
let state = app.state::<ServerState>();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(child) = guard.take() {
|
||||
if let Some(child) = state.0.lock().unwrap().take() {
|
||||
let _ = child.kill();
|
||||
println!("Killed tracked server child.");
|
||||
}
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.arg("-f")
|
||||
.arg("tachidesk")
|
||||
.status();
|
||||
|
||||
#[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();
|
||||
|
||||
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("io.github.Youwes09.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 = strip_unc(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] path: {:?} exists: {}", java, 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 = {:?}", binary));
|
||||
|
||||
if !binary.trim().is_empty() {
|
||||
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
||||
if path.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: path.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: path.parent().map(|p| p.to_path_buf()),
|
||||
});
|
||||
}
|
||||
do_log(log, "[resolve] user path not found, falling through");
|
||||
}
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(bin_dir) = exe.parent() {
|
||||
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||
let p = bin_dir.join(name);
|
||||
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(bin_dir.to_path_buf()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let resource_dir = {
|
||||
let raw = app.path().resource_dir().unwrap_or_default();
|
||||
let stripped = strip_unc(raw);
|
||||
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
|
||||
stripped
|
||||
};
|
||||
|
||||
#[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={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
||||
|
||||
match find_java_in_bundle(&bundle_dir, log) {
|
||||
Some(java) if jar.exists() => {
|
||||
do_log(log, "[resolve] 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),
|
||||
});
|
||||
}
|
||||
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
|
||||
let p = resource_dir.join(name);
|
||||
do_log(log, &format!("[resolve] sidecar: {:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(resource_dir.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||
let jar = std::fs::read_dir(&resource_dir)
|
||||
.ok()
|
||||
.and_then(|mut rd| {
|
||||
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
});
|
||||
|
||||
if let Some(jar_path) = jar {
|
||||
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||
return Ok(ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||
working_dir: Some(resource_dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||
let macos_dir = resource_dir.parent().map(|p| p.join("MacOS")).unwrap_or_default();
|
||||
|
||||
let candidates = [
|
||||
"suwayomi-server",
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
"suwayomi-server-x86_64-apple-darwin",
|
||||
"suwayomi-launcher",
|
||||
"suwayomi-launcher.sh",
|
||||
"tachidesk-server",
|
||||
];
|
||||
|
||||
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() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||
#[cfg(target_os = "windows")]
|
||||
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||
|
||||
if found {
|
||||
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
|
||||
}
|
||||
}
|
||||
|
||||
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<(), String> {
|
||||
let state = app.state::<ServerState>();
|
||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
||||
{
|
||||
let guard = state.0.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
println!("Server already running, skipping spawn.");
|
||||
let state = app.state::<ServerState>();
|
||||
if state.0.lock().unwrap().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let shell = app.shell();
|
||||
match shell.command(&binary).spawn() {
|
||||
let data_dir = suwayomi_data_dir();
|
||||
let log_path = data_dir.join("moku-spawn.log");
|
||||
let _ = std::fs::create_dir_all(&data_dir);
|
||||
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
|
||||
|
||||
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
|
||||
|
||||
seed_server_conf(&data_dir);
|
||||
|
||||
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
||||
e
|
||||
})?;
|
||||
|
||||
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||
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={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
|
||||
|
||||
let cmd = app.shell()
|
||||
.command(&invocation.bin)
|
||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||
.args(&invocation.args)
|
||||
.current_dir(&working_dir);
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok((_rx, child)) => {
|
||||
println!("Spawned server: {}", binary);
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
*guard = Some(child);
|
||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to spawn {}: {}", binary, e);
|
||||
Err(e.to_string())
|
||||
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,16 +510,146 @@ fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
|
||||
#[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.".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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_app(app: tauri::AppHandle) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_path(path: String) -> Result<(), String> {
|
||||
let p = std::path::Path::new(path.trim());
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.set_title("Choose Downloads Folder")
|
||||
.blocking_pick_folder()
|
||||
.map(|p| p.to_string())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_discord_rpc::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_os::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)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_storage_info,
|
||||
get_default_downloads_path,
|
||||
check_path_exists,
|
||||
create_directory,
|
||||
migrate_downloads,
|
||||
spawn_server,
|
||||
kill_server,
|
||||
get_scale_factor,
|
||||
get_platform_ui_scale,
|
||||
list_releases,
|
||||
download_and_install_update,
|
||||
restart_app,
|
||||
open_path,
|
||||
pick_downloads_folder,
|
||||
])
|
||||
.setup(|_app| Ok(()))
|
||||
.on_window_event(|window, event| {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.2.0",
|
||||
"identifier": "dev.moku.app",
|
||||
"version": "0.8.0",
|
||||
"identifier": "io.github.Youwes09.Moku.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
@@ -17,7 +17,8 @@
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false
|
||||
"decorations": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -26,18 +27,32 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["appimage"],
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"externalBin": [],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"installMode": "currentUser"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
||||
"endpoints": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,8 @@
|
||||
"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,498 @@
|
||||
<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 { platform } from "@tauri-apps/plugin-os";
|
||||
import { gql } from "./lib/client";
|
||||
import logoUrl from "./assets/moku-icon-splash.svg";
|
||||
import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
|
||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
||||
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||
import Layout from "./components/chrome/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/chrome/TitleBar.svelte";
|
||||
import Toaster from "./components/chrome/Toaster.svelte";
|
||||
import SplashScreen from "./components/chrome/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();
|
||||
const isWindows = platform() === "windows";
|
||||
|
||||
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 loginRequired = $state(false);
|
||||
let loginUser = $state(store.settings.serverAuthUser ?? "");
|
||||
let loginPass = $state("");
|
||||
let loginError = $state<string | null>(null);
|
||||
let loginBusy = $state(false);
|
||||
let unsupportedMode = $state(false);
|
||||
|
||||
let platformScale = $state(1.0);
|
||||
let _appliedZoom = -1;
|
||||
let _vhRafId: number | null = null;
|
||||
|
||||
function applyZoom() {
|
||||
const uiZoom = store.settings.uiZoom ?? 1.0;
|
||||
if (uiZoom === _appliedZoom) return;
|
||||
_appliedZoom = uiZoom;
|
||||
|
||||
const pct = uiZoom * 100;
|
||||
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
|
||||
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
|
||||
document.documentElement.style.zoom = `${pct}%`;
|
||||
|
||||
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
|
||||
_vhRafId = requestAnimationFrame(() => {
|
||||
_vhRafId = null;
|
||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}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(() => {
|
||||
void store.settings.uiZoom;
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
|
||||
let paused = false;
|
||||
|
||||
const poll = () => {
|
||||
if (paused) return;
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||
};
|
||||
|
||||
poll();
|
||||
pollInterval = setInterval(poll, 2000);
|
||||
|
||||
const onVisibility = () => { paused = document.hidden; };
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
let unlistenFocus: (() => void) | undefined;
|
||||
win.onFocusChanged(({ payload: focused }) => {
|
||||
paused = !focused;
|
||||
}).then(fn => { unlistenFocus = fn; });
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
unlistenFocus?.();
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
loginRequired = false;
|
||||
let tries = 0;
|
||||
|
||||
async function probe() {
|
||||
if (cancelProbe) return;
|
||||
tries++;
|
||||
const result = await probeServer();
|
||||
if (cancelProbe) return;
|
||||
|
||||
if (result === "ok") {
|
||||
serverProbeOk = true;
|
||||
loginRequired = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === "auth_required") {
|
||||
serverProbeOk = true;
|
||||
const savedUser = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const savedPass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
if (savedUser && savedPass) {
|
||||
try {
|
||||
await loginBasic(savedUser, savedPass);
|
||||
loginRequired = false;
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
loginRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === "unsupported_mode") {
|
||||
serverProbeOk = true;
|
||||
unsupportedMode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
|
||||
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.0);
|
||||
applyZoom();
|
||||
|
||||
store.isFullscreen = await win.isFullscreen();
|
||||
|
||||
const unlistenResize = await win.onResized(async () => {
|
||||
store.isFullscreen = await win.isFullscreen();
|
||||
});
|
||||
|
||||
const unlistenScale = await win.onScaleChanged(async (event) => {
|
||||
platformScale = event.payload.scaleFactor;
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
if (store.settings.autoStartServer) {
|
||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||
if (err?.kind === "NotConfigured") {
|
||||
notConfigured = true;
|
||||
} else {
|
||||
console.warn("Could not start server:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startProbe();
|
||||
|
||||
type P = { chapterId: number; mangaId: number; progress: number }[];
|
||||
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
|
||||
|
||||
return () => {
|
||||
cancelProbe = true;
|
||||
unlistenResize();
|
||||
unlistenScale();
|
||||
destroyRpc();
|
||||
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);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (store.settings.discordRpc) {
|
||||
initRpc();
|
||||
} else {
|
||||
clearReading();
|
||||
destroyRpc();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!store.activeChapter) {
|
||||
if (store.settings.discordRpc) setIdle();
|
||||
}
|
||||
});
|
||||
|
||||
function handleZoomKey(e: KeyboardEvent) {
|
||||
if (!e.ctrlKey) return;
|
||||
if (e.key === "=" || e.key === "+") {
|
||||
e.preventDefault();
|
||||
store.settings.uiZoom = Math.min(2.0, Math.round(((store.settings.uiZoom ?? 1.0) + 0.1) * 10) / 10);
|
||||
} else if (e.key === "-") {
|
||||
e.preventDefault();
|
||||
store.settings.uiZoom = Math.max(0.5, Math.round(((store.settings.uiZoom ?? 1.0) - 0.1) * 10) / 10);
|
||||
} else if (e.key === "0") {
|
||||
e.preventDefault();
|
||||
store.settings.uiZoom = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
window.addEventListener("keydown", handleZoomKey);
|
||||
return () => window.removeEventListener("keydown", handleZoomKey);
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
if (!loginUser.trim() || !loginPass.trim()) {
|
||||
loginError = "Username and password are required";
|
||||
return;
|
||||
}
|
||||
loginBusy = true;
|
||||
loginError = null;
|
||||
try {
|
||||
await loginBasic(loginUser.trim(), loginPass.trim());
|
||||
loginRequired = false;
|
||||
loginPass = "";
|
||||
loginError = null;
|
||||
appReady = true;
|
||||
} catch (e: any) {
|
||||
loginError = e?.message ?? "Login failed";
|
||||
} finally {
|
||||
loginBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
failed = false;
|
||||
notConfigured = false;
|
||||
serverProbeOk = false;
|
||||
loginRequired = false;
|
||||
unsupportedMode = false;
|
||||
startProbe();
|
||||
}
|
||||
|
||||
function handleBypass() {
|
||||
cancelProbe = true;
|
||||
serverProbeOk = true;
|
||||
loginRequired = false;
|
||||
unsupportedMode = false;
|
||||
appReady = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if devSplash}
|
||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||
{:else if !appReady && !loginRequired && !unsupportedMode}
|
||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
||||
showCards={store.settings.splashCards ?? true}
|
||||
onReady={() => { appReady = true; }}
|
||||
onRetry={handleRetry}
|
||||
onBypass={handleBypass} />
|
||||
{:else if unsupportedMode}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card">
|
||||
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||
<p class="auth-title">moku</p>
|
||||
<span class="auth-mode-badge auth-mode-badge--warn">{
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth"
|
||||
}</span>
|
||||
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||
<p class="auth-body">
|
||||
<strong>{
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode"
|
||||
}</strong> is not supported. Switch your server to <strong>Basic Auth</strong> and update Settings → Security.
|
||||
</p>
|
||||
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Continue anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card">
|
||||
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||
<p class="auth-title">moku</p>
|
||||
<span class="auth-mode-badge">Basic Auth</span>
|
||||
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||
{#if loginError}
|
||||
<p class="auth-error">{loginError}</p>
|
||||
{/if}
|
||||
<div class="auth-fields">
|
||||
<input class="auth-input" type="text" placeholder="Username"
|
||||
bind:value={loginUser} disabled={loginBusy} autocomplete="username"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
<input class="auth-input" type="password" placeholder="Password"
|
||||
bind:value={loginPass} disabled={loginBusy} autocomplete="current-password"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
</div>
|
||||
<button class="auth-btn" onclick={handleLogin}
|
||||
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}>
|
||||
{loginBusy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div id="app-shell" class="root">
|
||||
{#if idle && !store.activeChapter}
|
||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => { idle = false; resetIdle(); }} />
|
||||
{/if}
|
||||
{#if !store.activeChapter}<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; }
|
||||
|
||||
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; }
|
||||
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
|
||||
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
|
||||
.auth-mode-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-full); padding: 2px 10px; }
|
||||
.auth-mode-badge--warn { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
||||
.auth-host { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: -4px 0 0; }
|
||||
.auth-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); margin: 0; }
|
||||
.auth-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.auth-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin: 0; width: 100%; box-sizing: border-box; }
|
||||
.auth-fields { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; }
|
||||
.auth-input { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); outline: none; box-sizing: border-box; transition: border-color var(--t-base), box-shadow var(--t-base); font-family: inherit; }
|
||||
.auth-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
.auth-input:disabled { opacity: 0.5; }
|
||||
.auth-btn { width: 100%; padding: 9px; border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-sm); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); }
|
||||
.auth-btn:hover:not(:disabled) { opacity: 0.85; }
|
||||
.auth-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.auth-btn--ghost { background: none; border-color: transparent; color: var(--text-faint); font-size: var(--text-xs); padding: 4px; }
|
||||
.auth-btn--ghost:hover:not(:disabled) { color: var(--text-muted); opacity: 1; }
|
||||
</style>
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { gql } from "./lib/client";
|
||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||
import "./styles/global.css";
|
||||
import { useStore } from "./store";
|
||||
import Layout from "./components/layout/Layout";
|
||||
import Reader from "./components/pages/Reader";
|
||||
import Settings from "./components/settings/Settings";
|
||||
import MangaPreview from "./components/explore/MangaPreview";
|
||||
import TitleBar from "./components/layout/TitleBar";
|
||||
import Toaster from "./components/layout/Toaster";
|
||||
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||
import s from "./App.module.css";
|
||||
|
||||
const MAX_ATTEMPTS = 30;
|
||||
|
||||
export default function App() {
|
||||
const activeChapter = useStore((s) => s.activeChapter);
|
||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
const addToast = useStore((s) => s.addToast);
|
||||
|
||||
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
|
||||
const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
|
||||
// appReady = ring filled + transition done, show main UI
|
||||
const [appReady, setAppReady] = useState(!settings.autoStartServer);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const [idle, setIdle] = useState(false);
|
||||
// dev tools: force show splash
|
||||
const [devSplash, setDevSplash] = useState(false);
|
||||
|
||||
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// expose devSplash trigger via window for settings
|
||||
useEffect(() => {
|
||||
(window as any).__mokuShowSplash = () => setDevSplash(true);
|
||||
return () => { delete (window as any).__mokuShowSplash; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appReady) return;
|
||||
function resetIdle() {
|
||||
setIdle(false);
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||
if (idleTimeoutMs === 0) return;
|
||||
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
|
||||
}
|
||||
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
|
||||
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
|
||||
resetIdle();
|
||||
return () => {
|
||||
events.forEach(e => window.removeEventListener(e, resetIdle));
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
};
|
||||
}, [appReady, settings.idleTimeoutMin]);
|
||||
|
||||
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
||||
for (const item of prev) {
|
||||
if (item.state !== "DOWNLOADING") continue;
|
||||
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
||||
const manga = item.chapter.manga;
|
||||
addToast({ kind:"success", title:"Chapter downloaded",
|
||||
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
||||
duration: 4000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyQueue(next: DownloadQueueItem[]) {
|
||||
detectCompletions(prevQueueRef.current, next);
|
||||
prevQueueRef.current = next;
|
||||
setActiveDownloads(next.map(item => ({
|
||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
||||
})));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
||||
}, [settings.uiScale]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = settings.theme ?? "dark";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const p = (e: MouseEvent) => e.preventDefault();
|
||||
document.addEventListener("contextmenu", p);
|
||||
return () => document.removeEventListener("contextmenu", p);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.autoStartServer) return;
|
||||
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
|
||||
console.warn("Could not start server:", err));
|
||||
return () => { invoke("kill_server").catch(() => {}); };
|
||||
}, [settings.autoStartServer, settings.serverBinary]);
|
||||
|
||||
// Poll until server responds
|
||||
useEffect(() => {
|
||||
if (serverProbeOk) return;
|
||||
let cancelled = false, tries = 0;
|
||||
async function probe() {
|
||||
if (cancelled) return;
|
||||
tries++;
|
||||
try {
|
||||
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({ query:"{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
|
||||
} catch {}
|
||||
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
|
||||
if (!cancelled) setTimeout(probe, 800);
|
||||
}
|
||||
const t = setTimeout(probe, 800);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [serverProbeOk, settings.serverUrl, retryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appReady) return;
|
||||
function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||
}
|
||||
poll();
|
||||
const id = setInterval(poll, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [appReady]);
|
||||
|
||||
useEffect(() => {
|
||||
type P = { chapterId:number; mangaId:number; progress:number }[];
|
||||
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
|
||||
return () => { unsub.then(fn => fn()); };
|
||||
}, [setActiveDownloads]);
|
||||
|
||||
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
|
||||
if (devSplash) {
|
||||
return (
|
||||
<SplashScreen
|
||||
mode="idle"
|
||||
showFps
|
||||
showCards={settings.splashCards ?? true}
|
||||
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading splash — shown until ring fills + transition completes
|
||||
if (!appReady) {
|
||||
return (
|
||||
<SplashScreen
|
||||
mode="loading"
|
||||
ringFull={serverProbeOk}
|
||||
failed={failed}
|
||||
showCards={settings.splashCards ?? true}
|
||||
onReady={() => setAppReady(true)}
|
||||
onRetry={() => {
|
||||
setFailed(false);
|
||||
setServerProbeOk(false);
|
||||
setRetryKey(k => k+1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
{idle && !activeChapter && (
|
||||
<SplashScreen
|
||||
mode="idle"
|
||||
showCards={settings.splashCards ?? true}
|
||||
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
|
||||
/>
|
||||
)}
|
||||
{!activeChapter && <TitleBar/>}
|
||||
<div className={s.content}>
|
||||
{activeChapter ? <Reader/> : <Layout/>}
|
||||
</div>
|
||||
{settingsOpen && <Settings/>}
|
||||
<MangaPreview/>
|
||||
<Toaster/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<g transform="translate(256,265) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,27 +1,22 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 500.000000 500.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)"
|
||||
fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" ry="112" fill="#091209"/>
|
||||
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,42 @@
|
||||
<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 "../series/SeriesDetail.svelte";
|
||||
import RecentActivity from "./RecentActivity.svelte";
|
||||
import Search from "../pages/Search.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 === "downloads"}
|
||||
<Downloads />
|
||||
{:else if store.navPage === "extensions"}
|
||||
<Extensions />
|
||||
{:else if store.navPage === "tracking"}
|
||||
<Tracking />
|
||||
{:else}
|
||||
<Home />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { display: flex; height: 100%; background: var(--bg-base); overflow: hidden; }
|
||||
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; }
|
||||
</style>
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
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">
|
||||
<Thumbnail src={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; }
|
||||
:global(.thumb) { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.session-count {
|
||||
position: absolute; bottom: -4px; right: -6px;
|
||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
||||
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
|
||||
}
|
||||
|
||||
.session-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.session-chapter { font-size: var(--text-xs); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ch-arrow { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
||||
.ch-page { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
|
||||
.session-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
||||
.play-pill {
|
||||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
||||
padding: 3px 8px; border-radius: var(--radius-full);
|
||||
opacity: 0; transform: translateX(4px);
|
||||
transition: opacity var(--t-base), transform var(--t-base);
|
||||
}
|
||||
|
||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
||||
:global(.empty-icon) { color: var(--text-faint); }
|
||||
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
||||
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, 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: "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.activeSource = null;
|
||||
store.genreFilter = "";
|
||||
}
|
||||
|
||||
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; overflow: hidden; min-height: 0; height: 100%; }
|
||||
.logo { width: 80px; height: 80px; flex-shrink: 0; 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; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
.tab { width: 36px; height: 36px; flex-shrink: 0; 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 { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
||||
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
||||
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,489 @@
|
||||
<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);
|
||||
let uiScale = $state(1);
|
||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
||||
|
||||
const logoLoadingSize = 140;
|
||||
const logoIdleSize = 128;
|
||||
const logoLockSize = 96;
|
||||
|
||||
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 - logoLoadingSize) / 2));
|
||||
const ringLeft = $derived(-((ringSize - logoLoadingSize) / 2));
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
|
||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
||||
} else {
|
||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
||||
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
|
||||
}
|
||||
animFrame = requestAnimationFrame(animateRing);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (mode === "loading" && !failed && !notConfigured) {
|
||||
animFrame = requestAnimationFrame(animateRing);
|
||||
return () => cancelAnimationFrame(animFrame);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!ringFull) return;
|
||||
cancelAnimationFrame(animFrame);
|
||||
ringProg = 1;
|
||||
if (lockEnabled && !pinUnlocked) {
|
||||
setTimeout(() => (pinVisible = true), 400);
|
||||
} else {
|
||||
setTimeout(() => triggerExit(onReady), 650);
|
||||
}
|
||||
});
|
||||
|
||||
$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 dotsInterval = setInterval(() => {
|
||||
dots = dots.length >= 3 ? "" : dots + ".";
|
||||
}, 420);
|
||||
|
||||
onMount(async () => {
|
||||
const win = getCurrentWindow();
|
||||
uiScale = await win.scaleFactor();
|
||||
|
||||
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; }
|
||||
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: 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[] = [];
|
||||
const laneW = vw / COLS;
|
||||
for (let layer = 0; layer < 3; layer++) {
|
||||
const cfg = LAYER_CFG[layer];
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
const seed = col * 31 + layer * 97 + 7;
|
||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
||||
const h = w * 1.44;
|
||||
const 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")!;
|
||||
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, paused = false;
|
||||
|
||||
function frame(now: number) {
|
||||
if (paused) { raf = 0; return; }
|
||||
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);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
paused = true;
|
||||
t0 = -1;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (!paused) return;
|
||||
paused = false;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
document.hidden ? pause() : resume();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
const unlistenFocus = win.onFocusChanged(({ payload: focused }) => {
|
||||
focused ? resume() : pause();
|
||||
});
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
unlistenFocus.then(f => f());
|
||||
};
|
||||
}
|
||||
</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:{logoLockSize}px;height:{logoLockSize}px">
|
||||
<div class="logo-glow"></div>
|
||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoLockSize}px;height:{logoLockSize}px;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:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
||||
<div class="logo-glow"></div>
|
||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoIdleSize}px;height:{logoIdleSize}px;border-radius:28px;display:block;position:relative" />
|
||||
</div>
|
||||
<p class="hint">press any key to continue</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div style="position:relative;width:{logoLoadingSize}px;height:{logoLoadingSize}px;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:{logoLoadingSize}px;height:{logoLoadingSize}px;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={() => onRetry?.()}>Retry</button>
|
||||
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>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 } }
|
||||
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
|
||||
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
||||
|
||||
.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; }
|
||||
.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); }
|
||||
.pin-shake { animation: pinShake 0.42s ease; }
|
||||
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<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 os = platform();
|
||||
const isMac = os === "macos";
|
||||
const isWindows = os === "windows";
|
||||
|
||||
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>
|
||||
{:else if isWindows}
|
||||
<!-- On Windows, fullscreen hides the native titlebar — show a hoverable overlay so the user isn't locked in -->
|
||||
<div class="fullscreen-controls">
|
||||
<button onclick={() => win.setFullscreen(false)} title="Exit Fullscreen" aria-label="Exit Fullscreen">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</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}
|
||||
|
||||
<style>
|
||||
.fullscreen-controls {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.fullscreen-controls:hover { opacity: 1; }
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
/* Spacer to clear the native macOS traffic lights (~70px) */
|
||||
.mac-spacer {
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
button:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.close:hover { color: #fff; background: #c0392b; }
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { store, dismissToast } from "../../store/state.svelte";
|
||||
import type { Toast } from "../../store/state.svelte";
|
||||
|
||||
const EXIT_MS = 280;
|
||||
const leaving = new Set<string>();
|
||||
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(() => dismiss(t.id), dur));
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
if (leaving.has(id)) return;
|
||||
leaving.add(id);
|
||||
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id); }
|
||||
|
||||
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`);
|
||||
if (!el) { finalize(id); return; }
|
||||
|
||||
const h = el.offsetHeight;
|
||||
el.style.setProperty("--exit-h", `${h}px`);
|
||||
el.classList.add("leaving");
|
||||
setTimeout(() => finalize(id), EXIT_MS);
|
||||
}
|
||||
|
||||
function finalize(id: string) {
|
||||
leaving.delete(id);
|
||||
dismissToast(id);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const activeIds = new Set(store.toasts.map(t => t.id));
|
||||
store.toasts.forEach(schedule);
|
||||
for (const [id, timer] of timers) {
|
||||
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); }
|
||||
}
|
||||
});
|
||||
|
||||
const icons: Record<Toast["kind"], string> = {
|
||||
success: "M20 6L9 17l-5-5",
|
||||
error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
|
||||
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
|
||||
role="alert"
|
||||
class="toast toast-{t.kind}"
|
||||
data-toast-id={t.id}
|
||||
onclick={() => dismiss(t.id)}
|
||||
>
|
||||
<div class="accent-bar"></div>
|
||||
<span class="icon">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={icons[t.kind]} />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="body">
|
||||
<p class="title">{t.title}</p>
|
||||
<p class="sub">{t.body ?? '\u00a0'}</p>
|
||||
</div>
|
||||
</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: 5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px var(--sp-3) 12px 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
|
||||
pointer-events: all;
|
||||
width: 280px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
will-change: transform, opacity;
|
||||
animation: slideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.toast:hover {
|
||||
border-color: var(--border-base);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.06) inset;
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
.toast:active { transform: translateX(0) scale(0.98); }
|
||||
|
||||
:global(.toast.leaving) {
|
||||
animation: slideOut 0.28s cubic-bezier(0.4, 0, 1, 1) forwards !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(20px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
0% { opacity: 1; transform: translateX(0) scale(1); max-height: var(--exit-h, 80px); margin-bottom: 0; }
|
||||
40% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: var(--exit-h, 80px); margin-bottom: 0; }
|
||||
100% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: 0; margin-bottom: -5px; }
|
||||
}
|
||||
|
||||
.accent-bar {
|
||||
width: 3px;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0 2px 2px 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.toast-success .accent-bar { background: var(--accent-fg); }
|
||||
.toast-error .accent-bar { background: var(--color-error); }
|
||||
.toast-info .accent-bar { background: var(--text-faint); }
|
||||
.toast-download .accent-bar { background: var(--accent-fg); }
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-success .icon { color: var(--accent-fg); }
|
||||
.toast-error .icon { color: var(--color-error); }
|
||||
.toast-info .icon { color: var(--text-muted); }
|
||||
.toast-download .icon { color: var(--accent-fg); }
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -1,83 +0,0 @@
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-1);
|
||||
min-width: 190px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0,0,0,0.08),
|
||||
0 4px 12px rgba(0,0,0,0.35),
|
||||
0 16px 40px rgba(0,0,0,0.25);
|
||||
animation: scaleIn 0.1s ease both;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: 5px var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.item:hover:not(:disabled),
|
||||
.itemFocused:not(:disabled) {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Icon area — fixed-width column so labels align */
|
||||
.itemIconWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.item:hover .itemIconWrap,
|
||||
.itemFocused .itemIconWrap {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
flex: 1;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Danger variant */
|
||||
.itemDanger { color: var(--color-error); }
|
||||
.itemDanger:hover:not(:disabled),
|
||||
.itemDanger.itemFocused:not(:disabled) {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
.itemIconDanger { color: var(--color-error) !important; opacity: 0.7; }
|
||||
|
||||
/* Disabled */
|
||||
.itemDisabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background: var(--border-dim);
|
||||
margin: 3px var(--sp-1);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import s from "./ContextMenu.module.css";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
separator?: never;
|
||||
}
|
||||
|
||||
export interface ContextMenuSeparator {
|
||||
separator: true;
|
||||
label?: never;
|
||||
icon?: never;
|
||||
onClick?: never;
|
||||
danger?: never;
|
||||
disabled?: never;
|
||||
}
|
||||
|
||||
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [focused, setFocused] = useState<number>(-1);
|
||||
|
||||
// Build list of actionable (non-separator, non-disabled) indices for keyboard nav
|
||||
const actionable = items
|
||||
.map((_, i) => i)
|
||||
.filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled);
|
||||
|
||||
useEffect(() => {
|
||||
function onDown(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocused((prev) => {
|
||||
const cur = actionable.indexOf(prev);
|
||||
return actionable[(cur + 1) % actionable.length] ?? actionable[0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocused((prev) => {
|
||||
const cur = actionable.indexOf(prev);
|
||||
return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && focused >= 0) {
|
||||
e.preventDefault();
|
||||
const item = items[focused] as ContextMenuItem;
|
||||
if (item && !item.disabled) { item.onClick(); onClose(); }
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [onClose, focused, actionable, items]);
|
||||
|
||||
// Focus first item on open
|
||||
useEffect(() => {
|
||||
if (actionable.length) setFocused(actionable[0]);
|
||||
}, []);
|
||||
|
||||
const getPosition = useCallback(() => {
|
||||
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
||||
const scaledX = x / zoom;
|
||||
const scaledY = y / zoom;
|
||||
const menuW = 200;
|
||||
const menuH = items.length * 34;
|
||||
const vw = window.innerWidth / zoom;
|
||||
const vh = window.innerHeight / zoom;
|
||||
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
||||
const top = scaledY + menuH > vh ? scaledY - menuH : scaledY;
|
||||
return { left: Math.max(4, left), top: Math.max(4, top) };
|
||||
}, [x, y, items.length]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={s.menu}
|
||||
style={getPosition()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if ("separator" in item && item.separator) {
|
||||
return <div key={i} className={s.separator} />;
|
||||
}
|
||||
const mi = item as ContextMenuItem;
|
||||
const isFocused = focused === i;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className={[
|
||||
s.item,
|
||||
mi.danger ? s.itemDanger : "",
|
||||
mi.disabled ? s.itemDisabled : "",
|
||||
isFocused ? s.itemFocused : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
||||
onMouseEnter={() => !mi.disabled && setFocused(i)}
|
||||
onMouseLeave={() => setFocused(-1)}
|
||||
disabled={mi.disabled}
|
||||
>
|
||||
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
|
||||
{mi.icon ?? null}
|
||||
</span>
|
||||
<span className={s.itemLabel}>{mi.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
.root {
|
||||
padding: var(--sp-6);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.headerActions { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.iconBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-muted);
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
/* Loading state — accent tint so it's visually distinct */
|
||||
.iconBtnLoading {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.iconBtnLoading:hover:not(:disabled) {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
|
||||
.statusDotActive {
|
||||
background: var(--accent);
|
||||
animation: pulse 1.6s ease infinite;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--t-fast), opacity var(--t-base);
|
||||
}
|
||||
|
||||
.rowActive { border-color: var(--accent-dim); }
|
||||
|
||||
/* Fade out rows being removed */
|
||||
.rowRemoving { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
/* Thumbnail */
|
||||
.thumb {
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-overlay);
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.thumbImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Info block */
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mangaTitle {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chapterName {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pagesLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.progressWrap {
|
||||
height: 2px;
|
||||
background: var(--border-base);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* Right side */
|
||||
.rowRight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stateLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.removeBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 160px;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER,
|
||||
CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { DownloadStatus } from "../../lib/types";
|
||||
import s from "./DownloadQueue.module.css";
|
||||
|
||||
export default function DownloadQueue() {
|
||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [togglingPlay, setTogglingPlay] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
|
||||
// Apply status to local state + global store.
|
||||
// Completion toasting is handled globally in App.tsx — no duplication here.
|
||||
const applyStatus = useCallback((ds: DownloadStatus) => {
|
||||
setStatus(ds);
|
||||
setActiveDownloads(
|
||||
ds.queue.map((item) => ({
|
||||
chapterId: item.chapter.id,
|
||||
mangaId: item.chapter.mangaId,
|
||||
progress: item.progress,
|
||||
}))
|
||||
);
|
||||
}, [setActiveDownloads]);
|
||||
|
||||
async function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then((d) => applyStatus(d.downloadStatus))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
poll();
|
||||
const id = setInterval(poll, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function togglePlay() {
|
||||
if (togglingPlay) return;
|
||||
setTogglingPlay(true);
|
||||
const wasRunning = status?.state === "STARTED";
|
||||
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
|
||||
try {
|
||||
if (wasRunning) {
|
||||
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
||||
applyStatus(d.stopDownloader.downloadStatus);
|
||||
} else {
|
||||
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
||||
applyStatus(d.startDownloader.downloadStatus);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setTogglingPlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
if (clearing) return;
|
||||
setClearing(true);
|
||||
setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
|
||||
setActiveDownloads([]);
|
||||
try {
|
||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||
applyStatus(d.clearDownloader.downloadStatus);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function dequeue(chapterId: number) {
|
||||
if (dequeueing.has(chapterId)) return;
|
||||
setDequeueing((prev) => new Set(prev).add(chapterId));
|
||||
setStatus((prev) =>
|
||||
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
|
||||
);
|
||||
try {
|
||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||
poll();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setDequeueing((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(chapterId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const queue = status?.queue ?? [];
|
||||
const isRunning = status?.state === "STARTED";
|
||||
|
||||
function pagesDownloaded(progress: number, pageCount: number): number {
|
||||
return Math.round(progress * pageCount);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Downloads</h1>
|
||||
<div className={s.headerActions}>
|
||||
<button
|
||||
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||
onClick={togglePlay}
|
||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)}
|
||||
title={isRunning ? "Pause" : "Resume"}
|
||||
>
|
||||
{togglingPlay ? (
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||
) : isRunning ? (
|
||||
<Pause size={14} weight="fill" />
|
||||
) : (
|
||||
<Play size={14} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||
onClick={clear}
|
||||
disabled={clearing || queue.length === 0}
|
||||
title="Clear queue"
|
||||
>
|
||||
{clearing ? (
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||
) : (
|
||||
<Trash size={14} weight="regular" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.statusBar}>
|
||||
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
||||
<span className={s.statusText}>
|
||||
{togglingPlay
|
||||
? (isRunning ? "Pausing…" : "Starting…")
|
||||
: isRunning ? "Downloading" : "Paused"}
|
||||
</span>
|
||||
<span className={s.statusCount}>{queue.length} queued</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : queue.length === 0 ? (
|
||||
<div className={s.empty}>Queue is empty.</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{queue.map((item, i) => {
|
||||
const isActive = i === 0 && isRunning;
|
||||
const pages = item.chapter.pageCount ?? 0;
|
||||
const done = pagesDownloaded(item.progress, pages);
|
||||
const manga = item.chapter.manga;
|
||||
const isRemoving = dequeueing.has(item.chapter.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.chapter.id}
|
||||
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
|
||||
>
|
||||
{manga?.thumbnailUrl && (
|
||||
<div className={s.thumb}>
|
||||
<img
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.thumbImg}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.info}>
|
||||
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
|
||||
<span className={s.chapterName}>{item.chapter.name}</span>
|
||||
{pages > 0 && (
|
||||
<span className={s.pagesLabel}>
|
||||
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className={s.progressWrap}>
|
||||
<div
|
||||
className={s.progressBar}
|
||||
style={{ width: `${Math.round(item.progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.rowRight}>
|
||||
<span className={s.stateLabel}>{item.state}</span>
|
||||
{!isActive && (
|
||||
<button
|
||||
className={s.removeBtn}
|
||||
onClick={() => dequeue(item.chapter.id)}
|
||||
disabled={isRemoving}
|
||||
title="Remove from queue"
|
||||
>
|
||||
{isRemoving
|
||||
? <CircleNotch size={11} weight="light" className="anim-spin" />
|
||||
: <X size={12} weight="light" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
/* ── Header / Tab switcher ───────────────────────────────────────────────── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
|
||||
.tabActive {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
|
||||
/* Source picker */
|
||||
.sourcePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.sourcePickerLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sourceSelect {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-ui);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--t-base);
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.sourceSelect:focus { border-color: var(--border-strong); }
|
||||
|
||||
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-5) 0 var(--sp-6);
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────────────── */
|
||||
.section {
|
||||
margin-bottom: var(--sp-6);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-6) var(--sp-3);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sectionTitleIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.seeAll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.seeAll:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Horizontal scroll row ───────────────────────────────────────────────── */
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-6);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.row::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Card (shared by all rows) ───────────────────────────────────────────── */
|
||||
.card {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .title { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: filter var(--t-base);
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
.inLibraryBadge {
|
||||
position: absolute;
|
||||
bottom: var(--sp-1);
|
||||
left: var(--sp-1);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: var(--accent-fg);
|
||||
border-radius: 0 2px 0 0;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Ghost card — invisible placeholder to fill row trailing space */
|
||||
.ghostCard {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
aspect-ratio: 2 / 3;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
||||
.skeletonRow {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardSkeleton { flex-shrink: 0; width: 110px; }
|
||||
|
||||
.coverSkeleton {
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.titleSkeleton {
|
||||
height: 11px;
|
||||
margin-top: var(--sp-2);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
/* ── Genre drill-down grid ───────────────────────────────────────────────── */
|
||||
.drillRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.drillHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.drillTitle {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.drillGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.drillCard {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.drillCard:hover .cover { filter: brightness(1.06); }
|
||||
.drillCard:hover .title { color: var(--text-primary); }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-8) var(--sp-6);
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
gap: var(--sp-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── No source state ─────────────────────────────────────────────────────── */
|
||||
.noSource {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
/* ── Explore More end-cap card ───────────────────────────────────────────── */
|
||||
.exploreMoreCard {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color var(--t-base), background var(--t-base);
|
||||
padding: 0;
|
||||
}
|
||||
.exploreMoreCard:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
|
||||
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
|
||||
|
||||
.exploreMoreInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.exploreMoreIcon {
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.exploreMoreLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exploreMoreGenre {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useRef, memo } from "react";
|
||||
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||
import GenreDrillPage from "./GenreDrillPage";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import SourceList from "../sources/SourceList";
|
||||
import SourceBrowse from "../sources/SourceBrowse";
|
||||
import s from "./Explore.module.css";
|
||||
|
||||
// ── Frecency score ────────────────────────────────────────────────────────────
|
||||
|
||||
function frecencyScore(readAt: number, count: number): number {
|
||||
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
||||
return count / Math.log(hoursSince + 2);
|
||||
}
|
||||
|
||||
// ── Ghost / Skeleton ──────────────────────────────────────────────────────────
|
||||
|
||||
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
|
||||
const GHOST_COUNT = 3;
|
||||
const ROW_CAP = 25;
|
||||
|
||||
// Hijack vertical wheel delta → horizontal scroll on .row divs
|
||||
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
const el = e.currentTarget;
|
||||
const canScrollLeft = el.scrollLeft > 0;
|
||||
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
|
||||
if (!canScrollLeft && !canScrollRight) return;
|
||||
e.stopPropagation();
|
||||
el.scrollLeft += e.deltaY;
|
||||
}
|
||||
|
||||
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.skeletonRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cover image with fade-in ──────────────────────────────────────────────────
|
||||
|
||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Mini card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MiniCard = memo(function MiniCard({
|
||||
manga, onClick, onContextMenu, subtitle, progress,
|
||||
}: {
|
||||
manga: Manga;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
subtitle?: string;
|
||||
progress?: number;
|
||||
}) {
|
||||
return (
|
||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
{progress !== undefined && progress > 0 && (
|
||||
<div className={s.progressBar}>
|
||||
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.title}>{manga.title}</p>
|
||||
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Explore More end-cap ──────────────────────────────────────────────────────
|
||||
|
||||
const ExploreMoreCard = memo(function ExploreMoreCard({
|
||||
genre, onClick,
|
||||
}: { genre: string; onClick: () => void }) {
|
||||
return (
|
||||
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
|
||||
<div className={s.exploreMoreInner}>
|
||||
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
|
||||
<span className={s.exploreMoreLabel}>Explore more</span>
|
||||
<span className={s.exploreMoreGenre}>{genre}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Section ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
title, icon, onSeeAll, loading, children,
|
||||
}: {
|
||||
title: string; icon?: React.ReactNode; onSeeAll?: () => void;
|
||||
loading?: boolean; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.section}>
|
||||
<div className={s.sectionHeader}>
|
||||
<span className={s.sectionTitle}>
|
||||
<span className={s.sectionTitleIcon}>{icon}{title}</span>
|
||||
</span>
|
||||
{onSeeAll && (
|
||||
<button className={s.seeAll} onClick={onSeeAll}>
|
||||
See all <ArrowRight size={11} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? <SkeletonRow /> : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
type ExploreMode = "explore" | "sources";
|
||||
|
||||
export default function Explore() {
|
||||
const [mode, setMode] = useState<ExploreMode>("explore");
|
||||
const activeSource = useStore((s) => s.activeSource);
|
||||
const genreFilter = useStore((s) => s.genreFilter);
|
||||
|
||||
if (activeSource) return <SourceBrowse />;
|
||||
if (genreFilter) return <GenreDrillPage />;
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Explore</h1>
|
||||
<div className={s.tabs}>
|
||||
<button
|
||||
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("explore")}
|
||||
>
|
||||
<Compass size={11} weight="bold" /> Explore
|
||||
</button>
|
||||
<button
|
||||
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("sources")}
|
||||
>
|
||||
<List size={11} weight="bold" /> Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Keep ExploreFeed always mounted so data survives tab switches */}
|
||||
<div style={{ display: mode === "explore" ? "contents" : "none" }}><ExploreFeed /></div>
|
||||
{mode === "sources" && <SourceList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Explore feed ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
||||
|
||||
function ExploreFeed() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loadingLib, setLoadingLib] = useState(true);
|
||||
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
||||
const [loadingPopular, setLoadingPopular] = useState(true);
|
||||
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fetchedGenresRef = useRef<string>("");
|
||||
|
||||
const history = useStore((s) => s.history);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setPreviewManga = useStore((s) => s.setPreviewManga);
|
||||
const setGenreFilter = useStore((s) => s.setGenreFilter);
|
||||
const folders = useStore((s) => s.settings.folders);
|
||||
const addFolder = useStore((s) => s.addFolder);
|
||||
const assignMangaToFolder = useStore((s) => s.assignMangaToFolder);
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => { cache.clear(CACHE_KEYS.LIBRARY); })
|
||||
.catch(console.error),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...folders.map((f): ContextMenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Library + sources load (retries when suwayomi wasn't ready) ─────────────
|
||||
useEffect(() => {
|
||||
// If we already have data, no need to re-fetch (cache hit path)
|
||||
const alreadyLoaded = allManga.length > 0 && sources.length > 0;
|
||||
if (alreadyLoaded) return;
|
||||
|
||||
setLoadingLib(true);
|
||||
setLoadingPopular(true);
|
||||
setLoadError(false);
|
||||
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
|
||||
// Clear stale failed cache entries so we actually retry
|
||||
if (retryCount > 0) {
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
cache.clear(CACHE_KEYS.SOURCES);
|
||||
fetchedGenresRef.current = "";
|
||||
}
|
||||
|
||||
// Library — fire immediately, independent of sources
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
]).then(([all, lib]) => {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
||||
})
|
||||
).then(setAllManga)
|
||||
.catch((e) => { console.error(e); setLoadError(true); })
|
||||
.finally(() => setLoadingLib(false));
|
||||
|
||||
// Sources — then kick off popular AND genres simultaneously
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
||||
).then((allSources) => {
|
||||
if (allSources.length === 0) { setLoadingPopular(false); setLoadError(true); return; }
|
||||
|
||||
// Cap to 2 sources for the explore feed — halves the network calls
|
||||
const topSources = getTopSources(allSources).slice(0, 2);
|
||||
setSources(allSources);
|
||||
|
||||
// ── Popular — don't block genres ──────────────────────────────────
|
||||
cache.get(CACHE_KEYS.POPULAR, () =>
|
||||
Promise.allSettled(
|
||||
topSources.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "POPULAR", page: 1, query: null,
|
||||
}).then((d) => d.fetchSourceManga.mangas)
|
||||
)
|
||||
).then((results) => {
|
||||
const merged: Manga[] = [];
|
||||
for (const r of results)
|
||||
if (r.status === "fulfilled") merged.push(...r.value);
|
||||
return dedupeMangaByTitle(merged).slice(0, 30);
|
||||
})
|
||||
).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
|
||||
|
||||
// ── Genres — start immediately alongside popular using foundational
|
||||
// genres as a starting point; personalized genres replace these once
|
||||
// library loads. Results stream in as each genre resolves.
|
||||
const genresToFetch = FOUNDATIONAL_GENRES.slice(0, 3);
|
||||
const genreKey = genresToFetch.join(",");
|
||||
if (fetchedGenresRef.current === genreKey) return;
|
||||
fetchedGenresRef.current = genreKey;
|
||||
|
||||
setLoadingGenres(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
const streamingMap = new Map<string, Manga[]>();
|
||||
Promise.allSettled(
|
||||
genresToFetch.map((genre) =>
|
||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||
Promise.allSettled(
|
||||
topSources.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "SEARCH", page: 1, query: genre,
|
||||
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas)
|
||||
)
|
||||
).then((results) => {
|
||||
const merged: Manga[] = [];
|
||||
for (const r of results)
|
||||
if (r.status === "fulfilled") merged.push(...r.value);
|
||||
return dedupeMangaByTitle(merged).slice(0, 24);
|
||||
})
|
||||
).then((mangas) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Stream: each genre paints immediately as it resolves
|
||||
streamingMap.set(genre, mangas);
|
||||
setGenreResults(new Map(streamingMap));
|
||||
})
|
||||
)
|
||||
)
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
||||
})
|
||||
.catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [retryCount]);
|
||||
|
||||
// ── Frecency genres (derived from history + library) ──────────────────────
|
||||
const frecencyGenres = useMemo(() => {
|
||||
const mangaScores = new Map<number, number>();
|
||||
const mangaReadAt = new Map<number, number>();
|
||||
for (const entry of history) {
|
||||
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
|
||||
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
|
||||
mangaReadAt.set(entry.mangaId, entry.readAt);
|
||||
}
|
||||
const genreWeights = new Map<string, number>();
|
||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||
for (const [mangaId, count] of mangaScores.entries()) {
|
||||
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
||||
for (const genre of mangaMap.get(mangaId)?.genre ?? [])
|
||||
genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score);
|
||||
}
|
||||
if (genreWeights.size === 0)
|
||||
allManga.filter((m) => m.inLibrary).forEach((m) =>
|
||||
(m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
||||
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
|
||||
return Array.from(genreWeights.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([g]) => g);
|
||||
}, [allManga, history]);
|
||||
|
||||
// ── Re-fetch only when personalized genres differ from what's cached ───────
|
||||
useEffect(() => {
|
||||
if (frecencyGenres.length === 0 || sources.length === 0) return;
|
||||
|
||||
const genreKey = frecencyGenres.join(",");
|
||||
if (fetchedGenresRef.current === genreKey) return; // already fetched, cache hit
|
||||
fetchedGenresRef.current = genreKey;
|
||||
|
||||
setLoadingGenres(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
const topSources = getTopSources(sources).slice(0, 2);
|
||||
const streamingMap = new Map<string, Manga[]>();
|
||||
|
||||
Promise.allSettled(
|
||||
frecencyGenres.map((genre) =>
|
||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||
Promise.allSettled(
|
||||
topSources.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "SEARCH", page: 1, query: genre,
|
||||
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas)
|
||||
)
|
||||
).then((results) => {
|
||||
const merged: Manga[] = [];
|
||||
for (const r of results)
|
||||
if (r.status === "fulfilled") merged.push(...r.value);
|
||||
return dedupeMangaByTitle(merged).slice(0, 24);
|
||||
})
|
||||
).then((mangas) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
streamingMap.set(genre, mangas);
|
||||
setGenreResults(new Map(streamingMap));
|
||||
})
|
||||
)
|
||||
)
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
||||
}, [frecencyGenres, sources]);
|
||||
|
||||
function openManga(m: Manga) { setPreviewManga(m); }
|
||||
|
||||
// ── Continue reading ──────────────────────────────────────────────────────
|
||||
const continueReading = useMemo(() => {
|
||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||
const seen = new Set<number>();
|
||||
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
||||
for (const entry of history) {
|
||||
if (seen.has(entry.mangaId)) continue;
|
||||
seen.add(entry.mangaId);
|
||||
const manga = mangaMap.get(entry.mangaId);
|
||||
if (!manga) continue;
|
||||
result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 });
|
||||
if (result.length >= 12) break;
|
||||
}
|
||||
return result;
|
||||
}, [history, allManga]);
|
||||
|
||||
// ── Recommended ───────────────────────────────────────────────────────────
|
||||
const recommended = useMemo(() => {
|
||||
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
|
||||
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
||||
return allManga
|
||||
.filter((m) => m.inLibrary && !continueIds.has(m.id) &&
|
||||
frecencyGenres.some((g) => (m.genre ?? []).includes(g)))
|
||||
.slice(0, 20);
|
||||
}, [allManga, frecencyGenres, continueReading]);
|
||||
|
||||
const genresLoading = loadingGenres;
|
||||
|
||||
return (
|
||||
<div className={s.body}>
|
||||
|
||||
{(continueReading.length > 0 || loadingLib) && (
|
||||
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
|
||||
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
|
||||
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-cr-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(recommended.length > 0 || loadingLib) && (
|
||||
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{recommended.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(popularManga.length > 0 || loadingPopular) && (
|
||||
<Section
|
||||
title={sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
||||
icon={<Fire size={11} weight="bold" />}
|
||||
loading={loadingPopular}
|
||||
>
|
||||
{sources.length === 0 ? (
|
||||
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||
) : (
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{popularManga.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{frecencyGenres.map((genre) => {
|
||||
const items = genreResults.get(genre) ?? [];
|
||||
const isLoading = genresLoading && items.length === 0;
|
||||
if (!isLoading && items.length === 0) return null;
|
||||
return (
|
||||
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{items.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{items.length >= ROW_CAP && (
|
||||
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
|
||||
)}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loadingLib && !loadingPopular && !loadingGenres &&
|
||||
continueReading.length === 0 && recommended.length === 0 &&
|
||||
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
||||
<div className={s.empty}>
|
||||
{loadError ? (
|
||||
<>
|
||||
<span>Could not reach Suwayomi</span>
|
||||
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
|
||||
<button
|
||||
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Nothing to explore yet</span>
|
||||
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.loadingHint {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* Grid fills entire remaining height, no show-more needed */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
/* Smooth GPU-accelerated scrolling */
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .cardTitle { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
/* Solid bg shown while image fades in — matches skeleton color */
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: filter var(--t-base);
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
.inLibraryBadge {
|
||||
position: absolute;
|
||||
bottom: var(--sp-1);
|
||||
left: var(--sp-1);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin-top: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
/* Skeletons */
|
||||
.cardSkeleton { padding: 0; }
|
||||
.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); }
|
||||
.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.resultCount {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* Show more — spans full grid width */
|
||||
.showMoreCell {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-2) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.showMoreBtn:hover:not(:disabled) {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.showMoreBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||
import { useStore } from "../../store";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import s from "./GenreDrillPage.module.css";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 50; // how many items to show at once
|
||||
const INITIAL_PAGES = 3; // source API pages to fetch upfront per source
|
||||
const MAX_SOURCES = 12; // max sources to query concurrently
|
||||
const CONCURRENCY = 4; // parallel source fetches
|
||||
|
||||
async function runConcurrent<T>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < items.length) {
|
||||
if (signal.aborted) return;
|
||||
const item = items[i++];
|
||||
await fn(item).catch(() => {});
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||
}
|
||||
|
||||
// ── CoverImg ──────────────────────────────────────────────────────────────────
|
||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// ── GenreDrillPage ────────────────────────────────────────────────────────────
|
||||
export default function GenreDrillPage() {
|
||||
const genre = useStore((st) => st.genreFilter);
|
||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||
const settings = useStore((st) => st.settings);
|
||||
const folders = useStore((st) => st.settings.folders);
|
||||
const addFolder = useStore((st) => st.addFolder);
|
||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||
|
||||
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
|
||||
// Per-source next-page tracker; -1 means exhausted
|
||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||
const sourcesRef = useRef<Source[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!genre) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
setLoadingInitial(true);
|
||||
setSourceManga([]);
|
||||
setLibraryManga([]);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
nextPageRef.current = new Map();
|
||||
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
|
||||
// ── Library (fire-and-forget, doesn't block skeleton removal) ─────────
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
]).then(([all, lib]) => {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
||||
})
|
||||
)
|
||||
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||
|
||||
// ── Sources: stream results in as each source responds ────────────────
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang))
|
||||
).then(async (allSources) => {
|
||||
const sources = allSources.slice(0, MAX_SOURCES);
|
||||
sourcesRef.current = sources;
|
||||
// Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page
|
||||
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
||||
|
||||
await runConcurrent(sources, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageItems: Manga[] = [];
|
||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||
ctrl.signal,
|
||||
);
|
||||
pageItems.push(...d.fetchSourceManga.mangas);
|
||||
if (!d.fetchSourceManga.hasNextPage) {
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
break;
|
||||
} else if (page === INITIAL_PAGES) {
|
||||
// Has more pages beyond what we fetched upfront — mark for "load more"
|
||||
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||
// Dedupe by ID only — title dedup across sources is too aggressive and collapses
|
||||
// legitimate different-source results that share a common title (e.g. "Action" genre)
|
||||
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
||||
// Drop the skeleton as soon as we have anything
|
||||
setLoadingInitial(false);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||
}).catch((e) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||
});
|
||||
|
||||
return () => { ctrl.abort(); };
|
||||
}, [genre]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Derived merged list ────────────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre));
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
const srcAll = sourceManga.filter((m) => !libIds.has(m.id));
|
||||
return dedupeMangaById([...libMatches, ...srcAll]);
|
||||
}, [libraryManga, sourceManga, genre]);
|
||||
|
||||
// ── Load more ──────────────────────────────────────────────────────────────
|
||||
const hasMoreVisible = visibleCount < filtered.length;
|
||||
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||
const hasMore = hasMoreVisible || hasMoreNetwork;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore) return;
|
||||
|
||||
// If there are buffered results, just reveal the next page
|
||||
if (hasMoreVisible) {
|
||||
setVisibleCount((v) => v + PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch next pages from network
|
||||
const sources = sourcesRef.current.filter(
|
||||
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
||||
);
|
||||
if (!sources.length) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
await runConcurrent(sources, async (src) => {
|
||||
const page = nextPageRef.current.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||
ctrl.signal,
|
||||
);
|
||||
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
||||
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0)
|
||||
setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setVisibleCount((v) => v + PAGE_SIZE);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [loadingMore, hasMoreVisible, genre]);
|
||||
|
||||
// ── Context menu ──────────────────────────────────────────────────────────
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => {
|
||||
setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
})
|
||||
.catch(console.error),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...folders.map((f): ContextMenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const visibleItems = filtered.slice(0, visibleCount);
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<button className={s.back} onClick={() => setGenreFilter("")}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<span className={s.title}>{genre}</span>
|
||||
{loadingInitial && filtered.length === 0 ? null : (
|
||||
<span className={s.resultCount}>
|
||||
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
|
||||
</span>
|
||||
)}
|
||||
{!loadingInitial && hasMoreNetwork && (
|
||||
<span className={s.loadingHint}>More loading…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingInitial && filtered.length === 0 ? (
|
||||
<div className={s.grid}>
|
||||
{Array.from({ length: 50 }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.empty}>No manga found for "{genre}".</div>
|
||||
) : (
|
||||
<div className={s.grid}>
|
||||
{visibleItems.map((m) => (
|
||||
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
</div>
|
||||
<p className={s.cardTitle}>{m.title}</p>
|
||||
</button>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className={s.showMoreCell}>
|
||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display:"inline-block" }} /> Loading…</>
|
||||
: `Show more`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
/* ── Animations ──────────────────────────────────────────────────────────── */
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||
|
||||
/* ── Backdrop ────────────────────────────────────────────────────────────── */
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.72);
|
||||
z-index: var(--z-settings);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.12s ease both;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* ── Modal shell ─────────────────────────────────────────────────────────── */
|
||||
.modal {
|
||||
width: min(800px, calc(100vw - 48px));
|
||||
height: min(560px, calc(100vh - 80px));
|
||||
display: flex;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
animation: scaleIn 0.16s ease both;
|
||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* ── Cover column ────────────────────────────────────────────────────────── */
|
||||
.coverCol {
|
||||
width: 190px; flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
border-right: 1px solid var(--border-dim);
|
||||
display: flex; flex-direction: column;
|
||||
padding: var(--sp-5) var(--sp-4) var(--sp-4);
|
||||
gap: var(--sp-3);
|
||||
overflow-y: auto; overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.coverCol::-webkit-scrollbar { display: none; }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%; aspect-ratio: 2 / 3; object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.coverSpinner {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.35);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.coverActions {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ── Cover action buttons ────────────────────────────────────────────────── */
|
||||
.actionBtn {
|
||||
display: flex; align-items: center; justify-content: center; gap: var(--sp-2);
|
||||
width: 100%; padding: 7px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: none; color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||
.actionBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.actionBtnActive {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
|
||||
.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
|
||||
.actionBtnLabel {
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
||||
.folderWrap { position: relative; width: 100%; }
|
||||
|
||||
.folderMenu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-1);
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
z-index: 10;
|
||||
animation: scaleIn 0.1s ease both;
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
.folderEmpty {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
.folderItem {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
background: none; border: none; cursor: pointer; text-align: left;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.folderItemOn { color: var(--accent-fg); }
|
||||
|
||||
.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||
|
||||
.folderCreateRow {
|
||||
display: flex; gap: var(--sp-1); padding: var(--sp-1);
|
||||
}
|
||||
.folderInput {
|
||||
flex: 1; background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm); padding: 4px 8px;
|
||||
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
outline: none; min-width: 0;
|
||||
}
|
||||
.folderInput:focus { border-color: var(--border-focus); }
|
||||
|
||||
.folderOkBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
padding: 4px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.folderOkBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
|
||||
.folderNewBtn {
|
||||
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); background: none; border: none;
|
||||
cursor: pointer; text-align: left; width: 100%;
|
||||
transition: color var(--t-fast);
|
||||
}
|
||||
.folderNewBtn:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Content column ──────────────────────────────────────────────────────── */
|
||||
.content {
|
||||
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||
.contentHeader {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titleBlock {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-lg); font-weight: var(--weight-medium);
|
||||
color: var(--text-primary); letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.byline {
|
||||
font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.skByline {
|
||||
height: 14px; width: 55%;
|
||||
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||
animation: pulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); border: none; background: none;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||
.contentBody {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
display: flex; flex-direction: column; gap: var(--sp-4);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* ── Error banner ────────────────────────────────────────────────────────── */
|
||||
.errorBanner {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--color-warn, #f59e0b);
|
||||
background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent);
|
||||
border-radius: var(--radius-sm); padding: 6px var(--sp-3);
|
||||
}
|
||||
|
||||
/* ── Skeleton rows ───────────────────────────────────────────────────────── */
|
||||
.skRow {
|
||||
display: flex; gap: var(--sp-2); align-items: center;
|
||||
}
|
||||
.skBadge {
|
||||
height: 20px; width: 54px;
|
||||
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||
animation: pulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.skDesc {
|
||||
display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0;
|
||||
}
|
||||
.skLine {
|
||||
height: 13px; background: var(--bg-overlay);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: pulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────────────────────── */
|
||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||
|
||||
.badge {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||
padding: 3px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised); color: var(--text-faint);
|
||||
}
|
||||
.badgeGreen {
|
||||
background: color-mix(in srgb, #22c55e 12%, transparent);
|
||||
border-color: color-mix(in srgb, #22c55e 30%, transparent);
|
||||
color: #22c55e;
|
||||
}
|
||||
.badgeDim { /* default */ }
|
||||
.badgeAccent {
|
||||
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||
}
|
||||
.badgeUnread {
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
border-color: color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
color: #f59e0b;
|
||||
}
|
||||
.badgeNsfw {
|
||||
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ── Chapter box — clearly separated from description ────────────────────── */
|
||||
.chapterBox {
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.chapterLoading {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
}
|
||||
.chapterLoadingLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.chapterMeta {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.chapterLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.dlAllBtn {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 10px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.dlAllBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.progressTrack {
|
||||
height: 3px; background: var(--bg-overlay);
|
||||
border-radius: var(--radius-full); overflow: hidden;
|
||||
}
|
||||
.progressFill {
|
||||
height: 100%; background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.readBtn {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 8px var(--sp-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||
cursor: pointer; align-self: flex-start;
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.readBtn:hover { filter: brightness(1.1); }
|
||||
|
||||
/* ── Description block ───────────────────────────────────────────────────── */
|
||||
.descBlock {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: var(--text-sm); color: var(--text-muted);
|
||||
line-height: var(--leading-base);
|
||||
display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.descOpen {
|
||||
display: block; -webkit-line-clamp: unset; overflow: visible;
|
||||
}
|
||||
|
||||
.descToggle {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); background: none; border: none;
|
||||
cursor: pointer; padding: 0; align-self: flex-start;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.descToggle:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Genre tags ──────────────────────────────────────────────────────────── */
|
||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
|
||||
.genreTag {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised); color: var(--text-faint);
|
||||
}
|
||||
|
||||
.genreTagClickable {
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.genreTagClickable:hover {
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent-dim);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
/* ── Metadata table ──────────────────────────────────────────────────────── */
|
||||
.metaTable {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0;
|
||||
}
|
||||
.metaKey {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase; min-width: 56px; flex-shrink: 0;
|
||||
}
|
||||
.metaVal {
|
||||
font-size: var(--text-sm); color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
.metaLink {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: var(--text-sm); color: var(--accent-fg);
|
||||
text-decoration: none; transition: opacity var(--t-base);
|
||||
}
|
||||
.metaLink:hover { opacity: 0.75; }
|
||||
@@ -1,569 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import {
|
||||
X, BookmarkSimple, ArrowSquareOut, Play,
|
||||
CircleNotch, Books, CaretDown, FolderSimplePlus, Folder,
|
||||
} from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import s from "./MangaPreview.module.css";
|
||||
|
||||
export default function MangaPreview() {
|
||||
const previewManga = useStore((st) => st.previewManga);
|
||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||
const setActiveManga = useStore((st) => st.setActiveManga);
|
||||
const setNavPage = useStore((st) => st.setNavPage);
|
||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||
const openReader = useStore((st) => st.openReader);
|
||||
const addToast = useStore((st) => st.addToast);
|
||||
const folders = useStore((st) => st.settings.folders);
|
||||
const addFolder = useStore((st) => st.addFolder);
|
||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
||||
|
||||
const [manga, setManga] = useState<Manga | null>(null);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [loadingChapters, setLoadingChapters] = useState(false);
|
||||
const [togglingLib, setTogglingLib] = useState(false);
|
||||
const [descExpanded, setDescExpanded] = useState(false);
|
||||
const [folderOpen, setFolderOpen] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [queueingAll, setQueueingAll] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const detailAbort = useRef<AbortController | null>(null);
|
||||
const chapterAbort = useRef<AbortController | null>(null);
|
||||
const folderRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
detailAbort.current?.abort();
|
||||
chapterAbort.current?.abort();
|
||||
setPreviewManga(null);
|
||||
setManga(null);
|
||||
setChapters([]);
|
||||
setDescExpanded(false);
|
||||
setFolderOpen(false);
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
setFetchError(null);
|
||||
}, [setPreviewManga]);
|
||||
|
||||
// ── Fetch detail + chapters on open ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!previewManga) return;
|
||||
|
||||
// Abort any in-flight requests from previous manga
|
||||
detailAbort.current?.abort();
|
||||
chapterAbort.current?.abort();
|
||||
|
||||
const dCtrl = new AbortController();
|
||||
const cCtrl = new AbortController();
|
||||
detailAbort.current = dCtrl;
|
||||
chapterAbort.current = cCtrl;
|
||||
|
||||
setManga(null);
|
||||
setChapters([]);
|
||||
setDescExpanded(false);
|
||||
setFetchError(null);
|
||||
setLoadingDetail(true);
|
||||
setLoadingChapters(true);
|
||||
|
||||
const id = previewManga.id;
|
||||
|
||||
// ── Detail fetch strategy ─────────────────────────────────────────────
|
||||
// For source/explore manga we must call FETCH_MANGA (mutation that
|
||||
// hits the source and syncs to the local DB). GET_MANGA only works for
|
||||
// manga already in the local DB with full metadata.
|
||||
//
|
||||
// Fast path: if we already cached a full record, use it directly.
|
||||
// Slow path: always try FETCH_MANGA first — it never fails for valid IDs
|
||||
// and returns the richest data. Fall back to GET_MANGA if it errors.
|
||||
//
|
||||
(async (): Promise<Manga> => {
|
||||
const cacheKey = CACHE_KEYS.MANGA(id);
|
||||
|
||||
// Already have a cached rich record — no network needed
|
||||
if (cache.has(cacheKey)) {
|
||||
return cache.get(cacheKey, () =>
|
||||
Promise.resolve(previewManga as Manga)
|
||||
) as Promise<Manga>;
|
||||
}
|
||||
|
||||
// Try FETCH_MANGA first — works for all manga regardless of whether
|
||||
// they are in the local DB yet (it fetches from source and syncs).
|
||||
try {
|
||||
const d = await gql<{ fetchManga: { manga: Manga } }>(
|
||||
FETCH_MANGA, { id }, dCtrl.signal
|
||||
);
|
||||
return d.fetchManga.manga;
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") throw e;
|
||||
// FETCH_MANGA failed (e.g. source offline) — fall back to local DB
|
||||
const local = await gql<{ manga: Manga }>(
|
||||
GET_MANGA, { id }, dCtrl.signal
|
||||
).then((d) => d.manga);
|
||||
if (local) return local;
|
||||
throw new Error("Could not load manga details");
|
||||
}
|
||||
})()
|
||||
.then((fullManga) => {
|
||||
if (dCtrl.signal.aborted) return;
|
||||
// Cache the rich record so re-opening is instant
|
||||
if (!cache.has(CACHE_KEYS.MANGA(id))) {
|
||||
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
||||
}
|
||||
setManga(fullManga);
|
||||
setLoadingDetail(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e?.name === "AbortError") return;
|
||||
console.error("MangaPreview detail fetch:", e);
|
||||
// Show whatever sparse data we have from previewManga
|
||||
setManga(previewManga as Manga);
|
||||
setFetchError("Could not load full details — showing cached data");
|
||||
setLoadingDetail(false);
|
||||
});
|
||||
|
||||
// ── Chapter fetch — local DB first, fall back to source fetch ────────
|
||||
gql<{ chapters: { nodes: Chapter[] } }>(
|
||||
GET_CHAPTERS, { mangaId: id }, cCtrl.signal
|
||||
)
|
||||
.then(async (d) => {
|
||||
if (cCtrl.signal.aborted) return;
|
||||
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
// If no local chapters yet (explore/source manga), fetch from source
|
||||
if (nodes.length === 0) {
|
||||
try {
|
||||
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(
|
||||
FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal
|
||||
);
|
||||
if (!cCtrl.signal.aborted)
|
||||
nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
// Leave nodes empty — not a fatal error
|
||||
}
|
||||
}
|
||||
if (!cCtrl.signal.aborted) setChapters(nodes);
|
||||
})
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); });
|
||||
|
||||
return () => { dCtrl.abort(); cCtrl.abort(); };
|
||||
}, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Keyboard close ────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!previewManga) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [previewManga, close]);
|
||||
|
||||
// ── Folder outside click ──────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!folderOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (folderRef.current && !folderRef.current.contains(e.target as Node)) {
|
||||
setFolderOpen(false); setCreatingFolder(false); setNewFolderName("");
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [folderOpen]);
|
||||
|
||||
if (!previewManga) return null;
|
||||
|
||||
// Always show title/cover from previewManga immediately; upgrade to fetched manga when ready
|
||||
const displayManga = manga ?? previewManga;
|
||||
const totalCount = chapters.length;
|
||||
const readCount = chapters.filter((c) => c.isRead).length;
|
||||
const unreadCount = totalCount - readCount;
|
||||
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
||||
const bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
|
||||
const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false;
|
||||
|
||||
// Scanlators — deduplicated, non-empty
|
||||
const scanlators = [...new Set(
|
||||
chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim())
|
||||
)];
|
||||
|
||||
// Publication date range from chapter upload dates
|
||||
const uploadDates = chapters
|
||||
.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null)
|
||||
.filter((d): d is number => d !== null && !isNaN(d));
|
||||
const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
|
||||
const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
const statusLabel = displayManga.status
|
||||
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
|
||||
: null;
|
||||
|
||||
const continueChapter = (() => {
|
||||
if (!chapters.length) return null;
|
||||
const asc = [...chapters];
|
||||
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
||||
const firstUnread = asc.find((c) => !c.isRead);
|
||||
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
||||
return { ch: asc[0], label: "Read again" };
|
||||
})();
|
||||
|
||||
async function toggleLibrary() {
|
||||
if (!manga) return;
|
||||
setTogglingLib(true);
|
||||
const next = !manga.inLibrary;
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||
const updated = { ...manga, inLibrary: next };
|
||||
setManga(updated);
|
||||
// Update cache so subsequent opens reflect new state
|
||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
||||
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
setTogglingLib(false);
|
||||
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
||||
}
|
||||
|
||||
async function downloadAll() {
|
||||
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
||||
if (!ids.length) return;
|
||||
setQueueingAll(true);
|
||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
||||
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
||||
setQueueingAll(false);
|
||||
}
|
||||
|
||||
function openSeriesDetail() {
|
||||
setActiveManga(displayManga);
|
||||
setNavPage("library");
|
||||
close();
|
||||
}
|
||||
|
||||
function handleFolderCreate() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name || !previewManga) return;
|
||||
const newId = addFolder(name);
|
||||
assignMangaToFolder(newId, previewManga.id);
|
||||
setNewFolderName("");
|
||||
setCreatingFolder(false);
|
||||
}
|
||||
|
||||
const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={s.backdrop}
|
||||
ref={backdropRef}
|
||||
onClick={(e) => { if (e.target === backdropRef.current) close(); }}
|
||||
>
|
||||
<div className={s.modal} role="dialog" aria-label="Manga preview">
|
||||
|
||||
{/* ── Cover column ── */}
|
||||
<div className={s.coverCol}>
|
||||
<div className={s.coverWrap}>
|
||||
<img
|
||||
src={thumbUrl(previewManga.thumbnailUrl)}
|
||||
alt={displayManga.title}
|
||||
className={s.cover}
|
||||
/>
|
||||
{loadingDetail && (
|
||||
<div className={s.coverSpinner}>
|
||||
<CircleNotch size={18} weight="light" className="anim-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.coverActions}>
|
||||
<button
|
||||
className={[s.actionBtn, inLibrary ? s.actionBtnActive : ""].join(" ")}
|
||||
onClick={toggleLibrary}
|
||||
disabled={togglingLib || loadingDetail}
|
||||
>
|
||||
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
||||
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
|
||||
</button>
|
||||
|
||||
<button className={s.actionBtn} onClick={openSeriesDetail}>
|
||||
<Books size={13} weight="light" />
|
||||
Series Detail
|
||||
</button>
|
||||
|
||||
{/* Folder picker */}
|
||||
<div className={s.folderWrap} ref={folderRef}>
|
||||
<button
|
||||
className={[s.actionBtn, assignedFolders.length > 0 ? s.actionBtnFolder : ""].join(" ")}
|
||||
onClick={() => setFolderOpen((p) => !p)}
|
||||
>
|
||||
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
|
||||
<span className={s.actionBtnLabel}>
|
||||
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{folderOpen && (
|
||||
<div className={s.folderMenu}>
|
||||
{folders.length === 0 && !creatingFolder && (
|
||||
<p className={s.folderEmpty}>No folders yet</p>
|
||||
)}
|
||||
{folders.map((f) => {
|
||||
const isIn = f.mangaIds.includes(previewManga.id);
|
||||
return (
|
||||
<button key={f.id}
|
||||
className={[s.folderItem, isIn ? s.folderItemOn : ""].join(" ")}
|
||||
onClick={() => isIn
|
||||
? removeMangaFromFolder(f.id, previewManga.id)
|
||||
: assignMangaToFolder(f.id, previewManga.id)}
|
||||
>
|
||||
<Folder size={12} weight={isIn ? "fill" : "light"} />
|
||||
{isIn ? "✓ " : ""}{f.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className={s.folderDivider} />
|
||||
{creatingFolder ? (
|
||||
<div className={s.folderCreateRow}>
|
||||
<input autoFocus className={s.folderInput} placeholder="Folder name…"
|
||||
value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleFolderCreate();
|
||||
if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); }
|
||||
}}
|
||||
/>
|
||||
<button className={s.folderOkBtn} onClick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className={s.folderNewBtn} onClick={() => setCreatingFolder(true)}>+ New folder</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content column ── */}
|
||||
<div className={s.content}>
|
||||
|
||||
{/* Header — title visible immediately from previewManga */}
|
||||
<div className={s.contentHeader}>
|
||||
<div className={s.titleBlock}>
|
||||
<h2 className={s.title}>{displayManga.title}</h2>
|
||||
{loadingDetail
|
||||
? <div className={s.skByline} />
|
||||
: (displayManga.author || displayManga.artist)
|
||||
? <p className={s.byline}>
|
||||
{[displayManga.author, displayManga.artist]
|
||||
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
<button className={s.closeBtn} onClick={close}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className={s.contentBody}>
|
||||
|
||||
{/* Error banner */}
|
||||
{fetchError && (
|
||||
<div className={s.errorBanner}>{fetchError}</div>
|
||||
)}
|
||||
|
||||
{/* ── Badges ── */}
|
||||
{loadingDetail ? (
|
||||
<div className={s.skRow}>
|
||||
<div className={s.skBadge} />
|
||||
<div className={s.skBadge} style={{ width: 72 }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.badges}>
|
||||
{statusLabel && (
|
||||
<span className={[s.badge,
|
||||
displayManga.status === "ONGOING" ? s.badgeGreen : s.badgeDim
|
||||
].join(" ")}>{statusLabel}</span>
|
||||
)}
|
||||
{displayManga.source && (
|
||||
<span className={[s.badge, (displayManga.source as any).isNsfw ? s.badgeNsfw : ""].join(" ").trim()}>
|
||||
{displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""}
|
||||
</span>
|
||||
)}
|
||||
{inLibrary && <span className={[s.badge, s.badgeAccent].join(" ")}>In Library</span>}
|
||||
{!loadingChapters && unreadCount > 0 && (
|
||||
<span className={[s.badge, s.badgeUnread].join(" ")}>{unreadCount} unread</span>
|
||||
)}
|
||||
{!loadingChapters && bookmarkCount > 0 && (
|
||||
<span className={s.badge}>{bookmarkCount} bookmarked</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chapter section — visually separated box ── */}
|
||||
<div className={s.chapterBox}>
|
||||
{loadingChapters ? (
|
||||
<div className={s.chapterLoading}>
|
||||
<CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
<span className={s.chapterLoadingLabel}>Loading chapters…</span>
|
||||
</div>
|
||||
) : totalCount > 0 ? (
|
||||
<>
|
||||
<div className={s.chapterMeta}>
|
||||
<span className={s.chapterLabel}>
|
||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||
{readCount > 0 && ` · ${readCount} read`}
|
||||
{unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`}
|
||||
{downloadedCount > 0 && ` · ${downloadedCount} dl`}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<button className={s.dlAllBtn} onClick={downloadAll} disabled={queueingAll}>
|
||||
{queueingAll && <CircleNotch size={11} weight="light" className="anim-spin" />}
|
||||
{queueingAll ? "Queuing…" : "Download unread"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{readCount > 0 && (
|
||||
<div className={s.progressTrack}>
|
||||
<div className={s.progressFill} style={{ width: `${(readCount / totalCount) * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{continueChapter && (
|
||||
<button className={s.readBtn}
|
||||
onClick={() => { openReader(continueChapter.ch, chapters); close(); }}
|
||||
>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.label}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : !loadingDetail ? (
|
||||
<span className={s.chapterLabel} style={{ color: "var(--text-faint)" }}>
|
||||
No chapters in local library
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── Description — clearly separated from chapter block ── */}
|
||||
{loadingDetail ? (
|
||||
<div className={s.skDesc}>
|
||||
<div className={s.skLine} style={{ width: "100%" }} />
|
||||
<div className={s.skLine} style={{ width: "88%" }} />
|
||||
<div className={s.skLine} style={{ width: "70%" }} />
|
||||
</div>
|
||||
) : displayManga.description ? (
|
||||
<div className={s.descBlock}>
|
||||
<p className={[s.desc, descExpanded ? s.descOpen : ""].join(" ")}>
|
||||
{displayManga.description}
|
||||
</p>
|
||||
{displayManga.description.length > 220 && (
|
||||
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
|
||||
{descExpanded ? "Show less" : "Show more"}
|
||||
<CaretDown size={10} weight="light" style={{
|
||||
transform: descExpanded ? "rotate(180deg)" : "none",
|
||||
transition: "transform 0.15s ease",
|
||||
}} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Genre tags ── */}
|
||||
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
|
||||
<div className={s.genres}>
|
||||
{displayManga.genre.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
className={[s.genreTag, s.genreTagClickable].join(" ")}
|
||||
title={`Browse "${g}"`}
|
||||
onClick={() => {
|
||||
setGenreFilter(g);
|
||||
setNavPage("explore");
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Metadata table ── */}
|
||||
{!loadingDetail && (
|
||||
<div className={s.metaTable}>
|
||||
{displayManga.author && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Author</span>
|
||||
<span className={s.metaVal}>{displayManga.author}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.artist && displayManga.artist !== displayManga.author && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Artist</span>
|
||||
<span className={s.metaVal}>{displayManga.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
{statusLabel && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Status</span>
|
||||
<span className={s.metaVal}>{statusLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.source && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Source</span>
|
||||
<span className={s.metaVal}>{displayManga.source.displayName}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && scanlators.length > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span>
|
||||
<span className={s.metaVal}>{scanlators.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && firstUpload && lastUpload && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Published</span>
|
||||
<span className={s.metaVal}>
|
||||
{firstUpload.getTime() === lastUpload.getTime()
|
||||
? formatDate(firstUpload)
|
||||
: `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && downloadedCount > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Downloaded</span>
|
||||
<span className={s.metaVal}>{downloadedCount} / {totalCount} chapters</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && bookmarkCount > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Bookmarks</span>
|
||||
<span className={s.metaVal}>{bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.realUrl && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Link</span>
|
||||
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" className={s.metaLink}>
|
||||
Open <ArrowSquareOut size={11} weight="light" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
.root {
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||
}
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.headerActions { display: flex; gap: var(--sp-1); }
|
||||
.iconBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||
color: var(--text-muted); transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.4; }
|
||||
.iconBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.iconBtnActive:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); filter: brightness(1.1); }
|
||||
|
||||
.externalPanel {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
.externalHeader {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.externalTitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.externalRow {
|
||||
display: flex; gap: var(--sp-2);
|
||||
}
|
||||
.externalInput {
|
||||
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
||||
color: var(--text-primary); font-size: var(--text-sm); outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.externalInput:focus { border-color: var(--border-focus); }
|
||||
.externalInput:disabled { opacity: 0.5; }
|
||||
.externalInputError { border-color: var(--color-error) !important; }
|
||||
.externalError {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--color-error); letter-spacing: var(--tracking-wide);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.installBtn {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 6px 14px; border-radius: var(--radius-md);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
|
||||
transition: filter var(--t-base), opacity var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.installBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
.installBtnSuccess {
|
||||
background: var(--color-success, #2d6a3f); border-color: var(--color-success, #2d6a3f);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0;
|
||||
}
|
||||
.tabs { display: flex; gap: 2px; }
|
||||
.tab {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px; border-radius: var(--radius-md); border: none;
|
||||
background: none; color: var(--text-muted); cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.tabActive { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 5px 10px 5px 26px;
|
||||
color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.group { display: flex; flex-direction: column; }
|
||||
|
||||
.row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 8px var(--sp-3); border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
|
||||
.icon {
|
||||
width: 32px; height: 32px; border-radius: var(--radius-md);
|
||||
object-fit: cover; flex-shrink: 0; background: var(--bg-raised);
|
||||
}
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.name {
|
||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.meta {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.langTag {
|
||||
background: var(--bg-overlay); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm); padding: 1px 5px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.nsfwTag {
|
||||
background: transparent; border: 1px solid var(--color-error);
|
||||
border-radius: var(--radius-sm); padding: 1px 5px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--color-error); letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.updateBadge {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); border-radius: var(--radius-sm);
|
||||
padding: 2px 6px; flex-shrink: 0;
|
||||
}
|
||||
.updateBadgeSmall {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--accent-fg); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rowActions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.actionBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px; border-radius: var(--radius-md);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.actionBtn:hover { filter: brightness(1.1); }
|
||||
.actionBtnDim {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px; border-radius: var(--radius-md);
|
||||
background: none; color: var(--text-faint);
|
||||
border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.actionBtnDim:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||
|
||||
.expandBtn {
|
||||
display: flex; align-items: center; gap: 3px;
|
||||
padding: 4px 6px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.expandBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.expandCount {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.variants {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3));
|
||||
padding-left: var(--sp-3);
|
||||
border-left: 1px solid var(--border-dim);
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
.variantRow {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.variantRow:hover { background: var(--bg-raised); }
|
||||
.variantName {
|
||||
flex: 1; font-size: var(--text-sm); color: var(--text-muted);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.variantVersion {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||
}
|
||||
.variantActions { flex-shrink: 0; }
|
||||
|
||||
.empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex: 1; color: var(--text-faint);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* ── Panel shared styles ── */
|
||||
.externalPanel {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
.panelHeader {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.panelTitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.panelError {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--color-error); letter-spacing: var(--tracking-wide);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.externalRow { display: flex; gap: var(--sp-2); }
|
||||
.externalInput {
|
||||
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
||||
color: var(--text-primary); font-size: var(--text-sm); outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.externalInput:focus { border-color: var(--border-focus); }
|
||||
.externalInput:disabled { opacity: 0.5; }
|
||||
.externalInputError { border-color: var(--color-error) !important; }
|
||||
.installBtn {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 6px 14px; border-radius: var(--radius-md);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
|
||||
transition: filter var(--t-base), opacity var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.installBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
.installBtnSuccess {
|
||||
background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
|
||||
border-color: var(--accent-fg); color: var(--accent-fg);
|
||||
}
|
||||
|
||||
/* ── Repo list ── */
|
||||
.repoLoading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
.repoEmpty {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
padding: var(--sp-1) 2px;
|
||||
}
|
||||
.repoList {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.repoRow {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
}
|
||||
.repoUrl {
|
||||
flex: 1; font-family: var(--font-mono, monospace); font-size: var(--text-2xs);
|
||||
color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.repoRemoveBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.repoRemoveBtn:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
||||
.repoRemoveBtn:disabled { opacity: 0.4; }
|
||||
@@ -1,407 +0,0 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION,
|
||||
GET_SETTINGS, SET_EXTENSION_REPOS,
|
||||
} from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Extension } from "../../lib/types";
|
||||
import s from "./ExtensionList.module.css";
|
||||
|
||||
type Filter = "installed" | "available" | "updates" | "all";
|
||||
type Panel = null | "apk" | "repos";
|
||||
|
||||
function baseName(name: string): string {
|
||||
return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim();
|
||||
}
|
||||
|
||||
interface ExtGroup {
|
||||
base: string;
|
||||
primary: Extension;
|
||||
variants: Extension[];
|
||||
}
|
||||
|
||||
export default function ExtensionList() {
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [filter, setFilter] = useState<Filter>("installed");
|
||||
const [search, setSearch] = useState("");
|
||||
const [working, setWorking] = useState<Set<string>>(new Set());
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [panel, setPanel] = useState<Panel>(null);
|
||||
|
||||
// APK install state
|
||||
const [externalUrl, setExternalUrl] = useState("");
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [installSuccess, setInstallSuccess] = useState(false);
|
||||
|
||||
// Repo management state
|
||||
const [repos, setRepos] = useState<string[]>([]);
|
||||
const [reposLoading, setReposLoading] = useState(false);
|
||||
const [newRepoUrl, setNewRepoUrl] = useState("");
|
||||
const [repoError, setRepoError] = useState<string | null>(null);
|
||||
const [savingRepos, setSavingRepos] = useState(false);
|
||||
|
||||
const preferredLang = useStore((s) => s.settings.preferredExtensionLang);
|
||||
|
||||
async function load() {
|
||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
||||
.then((d) => setExtensions(d.extensions.nodes))
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
async function fetchFromRepo() {
|
||||
setRefreshing(true);
|
||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
||||
.then((d) => setExtensions(d.fetchExtensions.extensions))
|
||||
.catch(console.error)
|
||||
.finally(() => setRefreshing(false));
|
||||
}
|
||||
|
||||
async function loadRepos() {
|
||||
setReposLoading(true);
|
||||
try {
|
||||
const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS);
|
||||
setRepos(d.settings.extensionRepos ?? []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setReposLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRepos(updated: string[]) {
|
||||
setSavingRepos(true);
|
||||
try {
|
||||
const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(
|
||||
SET_EXTENSION_REPOS, { repos: updated }
|
||||
);
|
||||
setRepos(d.setSettings.settings.extensionRepos);
|
||||
} catch (e: unknown) {
|
||||
setRepoError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSavingRepos(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addRepo() {
|
||||
const url = newRepoUrl.trim();
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
setRepoError("URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
if (repos.includes(url)) {
|
||||
setRepoError("Repo already added");
|
||||
return;
|
||||
}
|
||||
setRepoError(null);
|
||||
setNewRepoUrl("");
|
||||
saveRepos([...repos, url]);
|
||||
}
|
||||
|
||||
function removeRepo(url: string) {
|
||||
saveRepos(repos.filter((r) => r !== url));
|
||||
}
|
||||
|
||||
const mutate = async (fn: () => Promise<unknown>, pkgName: string) => {
|
||||
setWorking((p) => new Set(p).add(pkgName));
|
||||
await fn().catch(console.error);
|
||||
await load();
|
||||
setWorking((p) => { const n = new Set(p); n.delete(pkgName); return n; });
|
||||
};
|
||||
|
||||
async function installExternal() {
|
||||
const url = externalUrl.trim();
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
setInstallError("URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
if (!url.endsWith(".apk")) {
|
||||
setInstallError("URL must point to an .apk file");
|
||||
return;
|
||||
}
|
||||
setInstalling(true);
|
||||
setInstallError(null);
|
||||
setInstallSuccess(false);
|
||||
try {
|
||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
||||
setInstallSuccess(true);
|
||||
setExternalUrl("");
|
||||
await load();
|
||||
setTimeout(() => {
|
||||
setPanel(null);
|
||||
setInstallSuccess(false);
|
||||
}, 1500);
|
||||
} catch (e: unknown) {
|
||||
setInstallError(e instanceof Error ? e.message : "Install failed");
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openPanel(p: Panel) {
|
||||
if (panel === p) {
|
||||
setPanel(null);
|
||||
return;
|
||||
}
|
||||
setPanel(p);
|
||||
setInstallError(null);
|
||||
setInstallSuccess(false);
|
||||
setExternalUrl("");
|
||||
setRepoError(null);
|
||||
setNewRepoUrl("");
|
||||
if (p === "repos") loadRepos();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchFromRepo().finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = extensions.filter((e) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
||||
const matchFilter =
|
||||
filter === "installed" ? e.isInstalled :
|
||||
filter === "available" ? !e.isInstalled :
|
||||
filter === "updates" ? e.hasUpdate : true;
|
||||
return matchSearch && matchFilter;
|
||||
});
|
||||
|
||||
const groups = useMemo<ExtGroup[]>(() => {
|
||||
const map = new Map<string, Extension[]>();
|
||||
for (const ext of filtered) {
|
||||
const key = baseName(ext.name);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(ext);
|
||||
}
|
||||
return Array.from(map.entries()).map(([base, all]) => {
|
||||
const primary =
|
||||
all.find((v) => v.lang === preferredLang) ??
|
||||
all.find((v) => v.lang === "en") ??
|
||||
all[0];
|
||||
const variants = all.filter((v) => v.pkgName !== primary.pkgName);
|
||||
return { base, primary, variants };
|
||||
});
|
||||
}, [filtered, preferredLang]);
|
||||
|
||||
const updateCount = extensions.filter((e) => e.hasUpdate).length;
|
||||
|
||||
const FILTERS: { id: Filter; label: string }[] = [
|
||||
{ id: "installed", label: "Installed" },
|
||||
{ id: "available", label: "Available" },
|
||||
{ id: "updates", label: updateCount > 0 ? `Updates (${updateCount})` : "Updates" },
|
||||
{ id: "all", label: "All" },
|
||||
];
|
||||
|
||||
function toggleExpand(base: string) {
|
||||
setExpanded((p) => {
|
||||
const n = new Set(p);
|
||||
n.has(base) ? n.delete(base) : n.add(base);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
function renderActions(ext: Extension) {
|
||||
if (working.has(ext.pkgName))
|
||||
return <CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />;
|
||||
if (ext.hasUpdate) return (
|
||||
<div className={s.rowActions}>
|
||||
<button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, update: true }), ext.pkgName)}>Update</button>
|
||||
<button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>
|
||||
</div>
|
||||
);
|
||||
if (ext.isInstalled)
|
||||
return <button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>;
|
||||
return <button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, install: true }), ext.pkgName)}>Install</button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Extensions</h1>
|
||||
<div className={s.headerActions}>
|
||||
<button
|
||||
className={[s.iconBtn, panel === "repos" ? s.iconBtnActive : ""].join(" ").trim()}
|
||||
onClick={() => openPanel("repos")} title="Manage repos">
|
||||
<GitBranch size={14} weight="light" />
|
||||
</button>
|
||||
<button
|
||||
className={[s.iconBtn, panel === "apk" ? s.iconBtnActive : ""].join(" ").trim()}
|
||||
onClick={() => openPanel("apk")} title="Install from URL">
|
||||
<Plus size={14} weight="light" />
|
||||
</button>
|
||||
<button className={s.iconBtn} onClick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
||||
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── APK install panel ── */}
|
||||
{panel === "apk" && (
|
||||
<div className={s.externalPanel}>
|
||||
<div className={s.panelHeader}>
|
||||
<span className={s.panelTitle}>Install from APK URL</span>
|
||||
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
<div className={s.externalRow}>
|
||||
<input
|
||||
className={[s.externalInput, installError ? s.externalInputError : ""].join(" ").trim()}
|
||||
placeholder="https://example.com/extension.apk"
|
||||
value={externalUrl}
|
||||
onChange={(e) => { setExternalUrl(e.target.value); setInstallError(null); }}
|
||||
onKeyDown={(e) => e.key === "Enter" && !installing && installExternal()}
|
||||
autoFocus
|
||||
disabled={installing}
|
||||
/>
|
||||
<button
|
||||
className={[s.installBtn, installSuccess ? s.installBtnSuccess : ""].join(" ").trim()}
|
||||
onClick={installExternal}
|
||||
disabled={installing || !externalUrl.trim()}
|
||||
>
|
||||
{installing
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: installSuccess
|
||||
? <><Check size={13} weight="bold" /> Done</>
|
||||
: "Install"}
|
||||
</button>
|
||||
</div>
|
||||
{installError && <div className={s.panelError}>{installError}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Repo management panel ── */}
|
||||
{panel === "repos" && (
|
||||
<div className={s.externalPanel}>
|
||||
<div className={s.panelHeader}>
|
||||
<span className={s.panelTitle}>Extension Repositories</span>
|
||||
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{reposLoading ? (
|
||||
<div className={s.repoLoading}>
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{repos.length === 0 ? (
|
||||
<div className={s.repoEmpty}>No repos configured.</div>
|
||||
) : (
|
||||
<div className={s.repoList}>
|
||||
{repos.map((url) => (
|
||||
<div key={url} className={s.repoRow}>
|
||||
<span className={s.repoUrl}>{url}</span>
|
||||
<button
|
||||
className={s.repoRemoveBtn}
|
||||
onClick={() => removeRepo(url)}
|
||||
disabled={savingRepos}
|
||||
title="Remove repo"
|
||||
>
|
||||
{savingRepos
|
||||
? <CircleNotch size={12} weight="light" className="anim-spin" />
|
||||
: <X size={12} weight="bold" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.externalRow} style={{ marginTop: "var(--sp-2)" }}>
|
||||
<input
|
||||
className={[s.externalInput, repoError ? s.externalInputError : ""].join(" ").trim()}
|
||||
placeholder="https://example.com/index.min.json"
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => { setNewRepoUrl(e.target.value); setRepoError(null); }}
|
||||
onKeyDown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
||||
disabled={savingRepos}
|
||||
/>
|
||||
<button
|
||||
className={s.installBtn}
|
||||
onClick={addRepo}
|
||||
disabled={savingRepos || !newRepoUrl.trim()}
|
||||
>
|
||||
{savingRepos
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: "Add"}
|
||||
</button>
|
||||
</div>
|
||||
{repoError && <div className={s.panelError}>{repoError}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.controls}>
|
||||
<div className={s.tabs}>
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.id} onClick={() => setFilter(f.id)}
|
||||
className={[s.tab, filter === f.id ? s.tabActive : ""].join(" ").trim()}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input className={s.search} placeholder="Search"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className={s.empty}>No extensions found.</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{groups.map(({ base, primary, variants }) => {
|
||||
const isExpanded = expanded.has(base);
|
||||
const hasVariants = variants.length > 0;
|
||||
return (
|
||||
<div key={base} className={s.group}>
|
||||
<div className={s.row}>
|
||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} className={s.icon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<div className={s.info}>
|
||||
<span className={s.name}>{base}</span>
|
||||
<span className={s.meta}>
|
||||
<span className={s.langTag}>{primary.lang.toUpperCase()}</span>
|
||||
{" "}v{primary.versionName}
|
||||
</span>
|
||||
</div>
|
||||
{primary.hasUpdate && <span className={s.updateBadge}>Update</span>}
|
||||
{renderActions(primary)}
|
||||
{hasVariants && (
|
||||
<button className={s.expandBtn} onClick={() => toggleExpand(base)}
|
||||
title={`${variants.length + 1} languages`}>
|
||||
{isExpanded ? <CaretDown size={12} weight="light" /> : <CaretRight size={12} weight="light" />}
|
||||
<span className={s.expandCount}>{variants.length + 1}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && hasVariants && (
|
||||
<div className={s.variants}>
|
||||
{variants.map((v) => (
|
||||
<div key={v.pkgName} className={s.variantRow}>
|
||||
<span className={s.langTag}>{v.lang.toUpperCase()}</span>
|
||||
<span className={s.variantName}>{v.name}</span>
|
||||
<span className={s.variantVersion}>v{v.versionName}</span>
|
||||
{v.hasUpdate && <span className={s.updateBadgeSmall}>↑</span>}
|
||||
<div className={s.variantActions}>{renderActions(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: var(--bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
/* GPU layer for main content area */
|
||||
transform: translateZ(0);
|
||||
contain: layout style;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useStore } from "../../store";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Library from "../pages/Library";
|
||||
import SeriesDetail from "../pages/SeriesDetail";
|
||||
import History from "../pages/History";
|
||||
import Search from "../pages/Search";
|
||||
import Explore from "../explore/Explore";
|
||||
import DownloadQueue from "../downloads/DownloadQueue";
|
||||
import ExtensionList from "../extensions/ExtensionList";
|
||||
import s from "./Layout.module.css";
|
||||
|
||||
export default function Layout() {
|
||||
const navPage = useStore((s) => s.navPage);
|
||||
const activeManga = useStore((s) => s.activeManga);
|
||||
|
||||
function renderContent() {
|
||||
if (activeManga) return <SeriesDetail />;
|
||||
switch (navPage) {
|
||||
case "library": return <Library />;
|
||||
case "search": return <Search />;
|
||||
case "history": return <History />;
|
||||
case "sources": return <Explore />;
|
||||
case "explore": return <Explore />;
|
||||
case "downloads": return <DownloadQueue />;
|
||||
case "extensions": return <ExtensionList />;
|
||||
default: return <Library />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<Sidebar />
|
||||
<main className={s.main}>{renderContent()}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
.root {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-void);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--sp-4) 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--sp-3);
|
||||
overflow: visible;
|
||||
/* Explicit reset — prevents browser from injecting a default button background */
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: opacity var(--t-base), transform var(--t-base);
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||
.logo:active { transform: scale(0.92); }
|
||||
/* Kill the focus ring that can render as a coloured glow on some GTK themes */
|
||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.logoIcon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: var(--accent);
|
||||
mask-image: url("../../assets/moku-icon.svg");
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
-webkit-mask-image: url("../../assets/moku-icon.svg");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
width: 100%;
|
||||
padding: 0 var(--sp-2);
|
||||
}
|
||||
|
||||
.tab {
|
||||
width: 36px; height: 36px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
/* Explicit resets — the green overlay was browser default button styles bleeding through */
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-md); }
|
||||
|
||||
.tabActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
/* Prevent hover state from overriding active colour */
|
||||
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
|
||||
.bottom {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
width: 100%; padding: var(--sp-3) var(--sp-2) 0;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.settingsBtn {
|
||||
width: 36px; height: 36px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
/* Same explicit resets */
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
||||
}
|
||||
.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||
.settingsBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
@@ -1,62 +0,0 @@
|
||||
import {
|
||||
Books, DownloadSimple, PuzzlePiece, Compass,
|
||||
GearSix, ClockCounterClockwise, MagnifyingGlass,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useStore, type NavPage } from "../../store";
|
||||
import s from "./Sidebar.module.css";
|
||||
|
||||
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||
{ id: "explore", icon: <Compass size={18} weight="light" />, label: "Explore" },
|
||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const navPage = useStore((state) => state.navPage);
|
||||
const setNavPage = useStore((state) => state.setNavPage);
|
||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||
const openSettings = useStore((state) => state.openSettings);
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
setNavPage(id);
|
||||
setActiveManga(null);
|
||||
setGenreFilter("");
|
||||
if (id !== "explore") setActiveSource(null);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
setNavPage("library");
|
||||
setActiveSource(null);
|
||||
setActiveManga(null);
|
||||
setLibraryFilter("library");
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={s.root}>
|
||||
{/* Logo click → back to library root */}
|
||||
<button className={s.logo} onClick={goHome} title="Go to Library" aria-label="Go to Library">
|
||||
<div className={s.logoIcon} />
|
||||
</button>
|
||||
<nav className={s.nav}>
|
||||
{TABS.map((tab) => (
|
||||
<button key={tab.id} title={tab.label}
|
||||
onClick={() => navigate(tab.id)}
|
||||
className={[s.tab, navPage === tab.id ? s.tabActive : ""].join(" ")}>
|
||||
{tab.icon}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={s.bottom}>
|
||||
<button className={s.settingsBtn} onClick={openSettings} title="Settings">
|
||||
<GearSix size={18} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import logoUrl from "../../assets/moku-icon.svg";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
export type SplashMode = "loading" | "idle";
|
||||
export const EXIT_MS = 320;
|
||||
|
||||
interface Props {
|
||||
mode: SplashMode;
|
||||
ringFull?: boolean;
|
||||
failed?: boolean;
|
||||
showCards?: boolean;
|
||||
showFps?: boolean;
|
||||
onReady?: () => void;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
// ── Hash ──────────────────────────────────────────────────────────────────────
|
||||
function hash(n: number): number {
|
||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
||||
}
|
||||
|
||||
// ── Card definition ───────────────────────────────────────────────────────────
|
||||
interface CardDef {
|
||||
layer: 0 | 1 | 2;
|
||||
cx: number;
|
||||
w: number;
|
||||
h: number;
|
||||
lines: number;
|
||||
alpha: number;
|
||||
speed: number;
|
||||
cycleSec: number;
|
||||
phase: number;
|
||||
travel: number;
|
||||
yStart: number;
|
||||
angleStart: number;
|
||||
tilt: number;
|
||||
}
|
||||
|
||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||
|
||||
const LAYER_CFG = [
|
||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
||||
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
||||
] as const;
|
||||
|
||||
const BUF = 80;
|
||||
const COLS = 14;
|
||||
|
||||
function buildCards(vw: number, vh: number): { cards: CardDef[]; trigs: CardTrig[] } {
|
||||
const cards: CardDef[] = [];
|
||||
const laneW = vw / COLS;
|
||||
for (let layer = 0; layer < 3; layer++) {
|
||||
const cfg = LAYER_CFG[layer];
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
const seed = col * 31 + layer * 97 + 7;
|
||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
||||
const h = w * 1.44;
|
||||
const maxNudge = (laneW - w) / 2 - 2;
|
||||
const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge);
|
||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
||||
const travel = vh + h + BUF;
|
||||
cards.push({
|
||||
layer: layer as 0 | 1 | 2,
|
||||
cx, w, h,
|
||||
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||
alpha: cfg.alpha,
|
||||
speed,
|
||||
cycleSec: travel / speed,
|
||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||
travel,
|
||||
yStart: vh + h / 2 + BUF / 2,
|
||||
angleStart: hash(seed + 3) * 50 - 25,
|
||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||
});
|
||||
}
|
||||
}
|
||||
const trigs: CardTrig[] = cards.map(c => ({
|
||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||
tiltRad: c.tilt * (Math.PI / 180),
|
||||
}));
|
||||
return { cards, trigs };
|
||||
}
|
||||
|
||||
// ── Rounded rect ──────────────────────────────────────────────────────────────
|
||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// ── Stamp builder ─────────────────────────────────────────────────────────────
|
||||
const STAMP_PAD = 6;
|
||||
|
||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||
const logW = Math.ceil(c.w + STAMP_PAD * 2);
|
||||
const logH = Math.ceil(c.h + STAMP_PAD * 2);
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = Math.round(logW * dpr);
|
||||
oc.height = Math.round(logH * dpr);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const x0 = STAMP_PAD;
|
||||
const y0 = STAMP_PAD;
|
||||
const coverH = (c.w * 0.72) * 1.05;
|
||||
const lineY0 = y0 + 3 + coverH + 5;
|
||||
|
||||
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
||||
rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.07)";
|
||||
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
||||
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
||||
ctx.lineWidth = 1.2;
|
||||
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.15)";
|
||||
rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.08)";
|
||||
rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
||||
|
||||
for (let li = 0; li < c.lines; li++) {
|
||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
||||
}
|
||||
|
||||
return oc;
|
||||
}
|
||||
|
||||
// ── Vignette builder ──────────────────────────────────────────────────────────
|
||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = Math.round(vw * dpr);
|
||||
oc.height = Math.round(vh * dpr);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
||||
g.addColorStop(0.15, "rgba(0,0,0,0)");
|
||||
g.addColorStop(1, "rgba(0,0,0,0.82)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, vw, vh);
|
||||
return oc;
|
||||
}
|
||||
|
||||
// ── Draw frame ────────────────────────────────────────────────────────────────
|
||||
function drawFrame(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
t: number,
|
||||
cw: number,
|
||||
ch: number,
|
||||
dpr: number,
|
||||
cards: CardDef[],
|
||||
trigs: CardTrig[],
|
||||
stamps: HTMLCanvasElement[],
|
||||
vignette: HTMLCanvasElement,
|
||||
) {
|
||||
ctx.clearRect(0, 0, cw, ch);
|
||||
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const c = cards[i];
|
||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
||||
|
||||
const alpha = p < 0.07
|
||||
? (p / 0.07) * c.alpha
|
||||
: p > 0.86
|
||||
? ((1 - p) / 0.14) * c.alpha
|
||||
: c.alpha;
|
||||
|
||||
if (alpha < 0.005) continue;
|
||||
|
||||
const cy = c.yStart - p * c.travel;
|
||||
const tg = trigs[i];
|
||||
const delta = tg.tiltRad * p;
|
||||
const cosDelta = Math.cos(delta);
|
||||
const sinDelta = Math.sin(delta);
|
||||
const cos = tg.cosA * cosDelta - tg.sinA * sinDelta;
|
||||
const sin = tg.sinA * cosDelta + tg.cosA * sinDelta;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.setTransform(
|
||||
cos * dpr, sin * dpr,
|
||||
-sin * dpr, cos * dpr,
|
||||
c.cx * dpr, cy * dpr,
|
||||
);
|
||||
// Draw stamp at its natural logical size.
|
||||
// The stamp was baked at (logical * dpr) physical pixels.
|
||||
// setTransform already applied dpr scaling, so drawing at logical size
|
||||
// means the stamp maps 1:1 to physical pixels — zero resampling, zero blur.
|
||||
const sw = stamps[i].width / dpr;
|
||||
const sh = stamps[i].height / dpr;
|
||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
||||
}
|
||||
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
||||
}
|
||||
|
||||
// ── Ring ──────────────────────────────────────────────────────────────────────
|
||||
function Ring({ progress }: { progress: number }) {
|
||||
const r = 44, sw = 2, pad = 8;
|
||||
const size = (r + pad) * 2, c = r + pad;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const arc = circ * Math.min(Math.max(progress, 0.025), 0.999);
|
||||
return (
|
||||
<svg width={size} height={size} style={{
|
||||
position: "absolute", pointerEvents: "none",
|
||||
top: -((size - 80) / 2), left: -((size - 80) / 2),
|
||||
}}>
|
||||
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={sw} />
|
||||
<circle cx={c} cy={c} r={r} fill="none" stroke="#4ade80" strokeWidth={sw}
|
||||
strokeLinecap="round" strokeDasharray={`${arc} ${circ}`}
|
||||
transform={`rotate(-90 ${c} ${c})`}
|
||||
style={{ transition: "stroke-dasharray 0.55s cubic-bezier(0.4,0,0.2,1)" }} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FPS counter ───────────────────────────────────────────────────────────────
|
||||
function FpsCounter() {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const times = useRef<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
function tick(now: number) {
|
||||
const arr = times.current;
|
||||
arr.push(now);
|
||||
if (arr.length > 60) arr.shift();
|
||||
if (arr.length > 1 && divRef.current) {
|
||||
const fps = Math.round((arr.length - 1) / ((arr[arr.length - 1] - arr[0]) / 1000));
|
||||
divRef.current.textContent = `${fps} fps`;
|
||||
divRef.current.style.color = fps >= 55 ? "#4ade80" : fps >= 30 ? "#facc15" : "#f87171";
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={divRef} style={{
|
||||
position: "fixed", top: 10, right: 14, zIndex: 10001,
|
||||
fontFamily: "var(--font-mono, 'Courier New', monospace)",
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: "0.08em",
|
||||
color: "#4ade80",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
borderRadius: 4, padding: "2px 7px",
|
||||
userSelect: "none", pointerEvents: "none",
|
||||
}}>-- fps</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── CardCanvas ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Strategy: best of both worlds.
|
||||
//
|
||||
// LAYOUT → logical pixels (window.innerWidth/Height or Tauri innerSize/scale)
|
||||
// Cards fill the actual window shape correctly at any size.
|
||||
//
|
||||
// QUALITY → physical pixels (Tauri innerSize + scaleFactor)
|
||||
// Canvas buffer = physical pixels, stamps baked at the true OS DPR.
|
||||
// No WebKitGTK lies, no late compositor hints, always pixel-perfect.
|
||||
//
|
||||
// On every resize both are re-derived together so fullscreen, half-split,
|
||||
// monitor switch — all produce crisp, correctly-proportioned cards.
|
||||
//
|
||||
function CardCanvas({ showFps }: { showFps: boolean }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false });
|
||||
if (!ctx) return;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
|
||||
// ── Live render state ────────────────────────────────────────────────────
|
||||
// The frame loop only ever reads from `live`. syncSize builds a complete
|
||||
// replacement object off-thread then swaps it in one atomic assignment —
|
||||
// no frame ever sees a half-rebuilt state.
|
||||
interface RenderState {
|
||||
cards: ReturnType<typeof buildCards>["cards"];
|
||||
trigs: ReturnType<typeof buildCards>["trigs"];
|
||||
stamps: HTMLCanvasElement[];
|
||||
vignette: HTMLCanvasElement;
|
||||
CW: number; CH: number; scale: number;
|
||||
}
|
||||
let live: RenderState | null = null;
|
||||
|
||||
// Track what we last built so we skip no-op resize events.
|
||||
let lastLogW = 0, lastLogH = 0, lastScale = 0;
|
||||
// Debounce: if a new resize arrives while one is in-flight, we only
|
||||
// want the most recent result. A simple generation counter handles this.
|
||||
let buildGen = 0;
|
||||
|
||||
async function syncSize() {
|
||||
const gen = ++buildGen;
|
||||
|
||||
const [phys, scale] = await Promise.all([
|
||||
win.innerSize(),
|
||||
win.scaleFactor(),
|
||||
]);
|
||||
|
||||
// Another resize fired while we were awaiting — our result is stale.
|
||||
if (gen !== buildGen) return;
|
||||
|
||||
const physW = phys.width;
|
||||
const physH = phys.height;
|
||||
const logW = physW / scale;
|
||||
const logH = physH / scale;
|
||||
|
||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
||||
|
||||
// Build everything into a local staging object — nothing visible changes yet.
|
||||
const built = buildCards(logW, logH);
|
||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
||||
const vig = buildVignette(logW, logH, scale);
|
||||
|
||||
// One atomic swap — the frame loop immediately sees the complete new state.
|
||||
// Canvas dimensions are updated here too so they're always in sync with
|
||||
// the render state that uses them.
|
||||
canvas!.width = physW;
|
||||
canvas!.height = physH;
|
||||
live = {
|
||||
cards: built.cards, trigs: built.trigs,
|
||||
stamps, vignette: vig,
|
||||
CW: physW, CH: physH, scale,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[SplashScreen] syncSize: logical ${Math.round(logW)}×${Math.round(logH)}`,
|
||||
`physical ${physW}×${physH} @${scale.toFixed(3)}×`,
|
||||
);
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => syncSize());
|
||||
ro.observe(canvas);
|
||||
syncSize();
|
||||
|
||||
let raf = 0, t0 = -1;
|
||||
function frame(now: number) {
|
||||
raf = requestAnimationFrame(frame);
|
||||
if (!live) return;
|
||||
if (t0 < 0) t0 = now;
|
||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
||||
drawFrame(ctx!, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
||||
}
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas ref={ref} style={{
|
||||
position: "absolute", inset: 0, pointerEvents: "none",
|
||||
width: "100%", height: "100%",
|
||||
}} />
|
||||
{showFps && <FpsCounter />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Static CSS ────────────────────────────────────────────────────────────────
|
||||
const STATIC_CSS = `
|
||||
@keyframes spIn { from{opacity:0;transform:scale(1.015)} to{opacity:1;transform:scale(1)} }
|
||||
@keyframes spOut { from{opacity:1;transform:scale(1)} to{opacity:0;transform:scale(0.96)} }
|
||||
@keyframes logoBreathe {
|
||||
0%,100%{transform:scale(1);filter:drop-shadow(0 0 0px rgba(255,255,255,0))}
|
||||
50% {transform:scale(1.04);filter:drop-shadow(0 0 18px rgba(255,255,255,0.12))}
|
||||
}
|
||||
@keyframes hintFade { 0%,100%{opacity:0.35} 50%{opacity:0.7} }
|
||||
`;
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
export default function SplashScreen({
|
||||
mode, ringFull = false, failed = false,
|
||||
showCards = true, showFps = false,
|
||||
onReady, onRetry, onDismiss,
|
||||
}: Props) {
|
||||
const [dots, setDots] = useState("");
|
||||
const [ringProg, setRingProg] = useState(0.025);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const exitLock = useRef(false);
|
||||
|
||||
function triggerExit(cb?: () => void) {
|
||||
if (exitLock.current) return;
|
||||
exitLock.current = true;
|
||||
setExiting(true);
|
||||
setTimeout(() => cb?.(), EXIT_MS);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!ringFull) return;
|
||||
setRingProg(1);
|
||||
const t = setTimeout(() => triggerExit(onReady), 650);
|
||||
return () => clearTimeout(t);
|
||||
}, [ringFull]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setDots(d => d.length >= 3 ? "" : d + "."), 420);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "idle" || !onDismiss) return;
|
||||
function handler() { triggerExit(onDismiss); }
|
||||
window.addEventListener("keydown", handler, { once: true });
|
||||
window.addEventListener("mousedown", handler, { once: true });
|
||||
window.addEventListener("touchstart", handler, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handler);
|
||||
window.removeEventListener("mousedown", handler);
|
||||
window.removeEventListener("touchstart", handler);
|
||||
};
|
||||
}, [mode, onDismiss]);
|
||||
|
||||
const isIdle = mode === "idle";
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "fixed", inset: 0, zIndex: 9999,
|
||||
background: "var(--bg-base)", overflow: "hidden",
|
||||
display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
cursor: isIdle ? "pointer" : "default",
|
||||
animation: exiting
|
||||
? `spOut ${EXIT_MS}ms cubic-bezier(0.4,0,1,1) both`
|
||||
: "spIn 0.35s cubic-bezier(0,0,0.2,1) both",
|
||||
}}>
|
||||
<style>{STATIC_CSS}</style>
|
||||
|
||||
{showCards && <CardCanvas showFps={showFps} />}
|
||||
|
||||
{isIdle ? (
|
||||
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<div style={{ position: "relative", width: 128, height: 128, marginBottom: 32 }}>
|
||||
<div style={{
|
||||
position: "absolute", inset: -20, borderRadius: "50%",
|
||||
background: "radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%)",
|
||||
animation: "logoBreathe 4s ease-in-out infinite",
|
||||
}} />
|
||||
<img src={logoUrl} alt="Moku" style={{
|
||||
width: 128, height: 128, borderRadius: 28,
|
||||
display: "block", position: "relative",
|
||||
animation: "logoBreathe 4s ease-in-out infinite",
|
||||
}} />
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)",
|
||||
letterSpacing: "0.22em", textTransform: "uppercase",
|
||||
margin: 0, userSelect: "none",
|
||||
animation: "hintFade 3.5s ease-in-out infinite",
|
||||
}}>press any key to continue</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ position: "relative", width: 80, height: 80, marginBottom: 20, zIndex: 1 }}>
|
||||
{!failed && <Ring progress={ringProg} />}
|
||||
<img src={logoUrl} alt="Moku"
|
||||
style={{ width: 80, height: 80, borderRadius: 18, display: "block" }} />
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: "var(--font-ui)", fontSize: 11, fontWeight: 500,
|
||||
letterSpacing: "0.26em", textTransform: "uppercase",
|
||||
color: "var(--text-secondary)", margin: "0 0 8px",
|
||||
zIndex: 1, userSelect: "none",
|
||||
}}>moku</p>
|
||||
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
||||
{failed ? (
|
||||
<>
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 11, color: "var(--color-error)", letterSpacing: "0.1em", margin: 0 }}>
|
||||
Could not reach Suwayomi
|
||||
</p>
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.05em", margin: 0, textAlign: "center", maxWidth: 240, lineHeight: "1.6" }}>
|
||||
Make sure tachidesk-server is on your PATH
|
||||
</p>
|
||||
<button onClick={onRetry} style={{
|
||||
marginTop: 4, padding: "5px 16px", borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--border-dim)", background: "var(--bg-raised)",
|
||||
color: "var(--text-muted)", cursor: "pointer",
|
||||
fontFamily: "var(--font-ui)", fontSize: 11, letterSpacing: "0.08em",
|
||||
}}>Retry</button>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.12em", margin: 0, minWidth: 160, textAlign: "center" }}>
|
||||
{ringFull ? "Ready" : `Initializing server${dots}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
/* Drag region covers the whole bar */
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
/* Controls must NOT be draggable */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.btnClose:hover {
|
||||
color: #fff;
|
||||
background: #c0392b;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import s from "./TitleBar.module.css";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
|
||||
export default function TitleBar() {
|
||||
return (
|
||||
<div className={s.bar} data-tauri-drag-region>
|
||||
<span className={s.title} data-tauri-drag-region>Moku</span>
|
||||
<div className={s.controls}>
|
||||
<button
|
||||
className={s.btn}
|
||||
onClick={() => win.minimize()}
|
||||
title="Minimize"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={s.btn}
|
||||
onClick={() => win.toggleMaximize()}
|
||||
title="Maximize"
|
||||
aria-label="Maximize"
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 9 9">
|
||||
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={[s.btn, s.btnClose].join(" ")}
|
||||
onClick={() => win.close()}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
.toaster {
|
||||
position: fixed;
|
||||
bottom: var(--sp-5);
|
||||
right: var(--sp-5);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
pointer-events: none;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--bg-raised);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
||||
pointer-events: all;
|
||||
animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Kind variants */
|
||||
.toast_success { border-color: var(--accent-dim); }
|
||||
.toast_success .toastIcon { color: var(--accent-fg); }
|
||||
|
||||
.toast_error { border-color: var(--color-error); }
|
||||
.toast_error .toastIcon { color: var(--color-error); }
|
||||
|
||||
.toast_download .toastIcon { color: var(--accent-fg); }
|
||||
.toast_info .toastIcon { color: var(--text-muted); }
|
||||
|
||||
.toastIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.toastBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toastTitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toastSub {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.toastClose {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.toastClose:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CheckCircle, X, WarningCircle, Info, DownloadSimple } from "@phosphor-icons/react";
|
||||
import { useStore } from "../../store";
|
||||
import s from "./Toaster.module.css";
|
||||
|
||||
export type ToastKind = "success" | "error" | "info" | "download";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
kind: ToastKind;
|
||||
title: string;
|
||||
body?: string;
|
||||
duration?: number; // ms, 0 = persistent
|
||||
}
|
||||
|
||||
// ── icons per kind ──────────────────────────────────────────────────────────
|
||||
|
||||
function ToastIcon({ kind }: { kind: ToastKind }) {
|
||||
const size = 15;
|
||||
const w = "light" as const;
|
||||
if (kind === "success") return <CheckCircle size={size} weight={w} />;
|
||||
if (kind === "error") return <WarningCircle size={size} weight={w} />;
|
||||
if (kind === "download") return <DownloadSimple size={size} weight={w} />;
|
||||
return <Info size={size} weight={w} />;
|
||||
}
|
||||
|
||||
// ── individual toast ─────────────────────────────────────────────────────────
|
||||
|
||||
function ToastItem({ toast }: { toast: Toast }) {
|
||||
const dismissToast = useStore((s) => s.dismissToast);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const duration = toast.duration ?? 3500;
|
||||
|
||||
useEffect(() => {
|
||||
if (duration === 0) return;
|
||||
timerRef.current = setTimeout(() => dismissToast(toast.id), duration);
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}, [toast.id, duration]);
|
||||
|
||||
return (
|
||||
<div className={[s.toast, s[`toast_${toast.kind}`]].join(" ")} role="alert">
|
||||
<span className={s.toastIcon}><ToastIcon kind={toast.kind} /></span>
|
||||
<div className={s.toastBody}>
|
||||
<p className={s.toastTitle}>{toast.title}</p>
|
||||
{toast.body && <p className={s.toastSub}>{toast.body}</p>}
|
||||
</div>
|
||||
<button className={s.toastClose} onClick={() => dismissToast(toast.id)} title="Dismiss">
|
||||
<X size={12} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── toaster container ────────────────────────────────────────────────────────
|
||||
|
||||
export default function Toaster() {
|
||||
const toasts = useStore((s) => s.toasts);
|
||||
|
||||
if (!toasts.length) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className={s.toaster} aria-live="polite">
|
||||
{toasts.map((t) => <ToastItem key={t.id} toast={t} />)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
||||
import { gql } from "../../lib/client";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
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">
|
||||
<Thumbnail src={manga.thumbnailUrl} alt={manga?.title} class="thumb-img" />
|
||||
</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); }
|
||||
:global(.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,376 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
||||
import { gql } from "../../lib/client";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
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 langFilter = $state<string | null>(null);
|
||||
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;
|
||||
const matchLang = langFilter === null || e.lang === langFilter;
|
||||
return matchSearch && matchFilter && matchLang;
|
||||
}));
|
||||
|
||||
const availableLangs = $derived(
|
||||
[...new Set(extensions
|
||||
.filter((e) => filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true)
|
||||
.map((e) => e.lang)
|
||||
)].sort()
|
||||
);
|
||||
|
||||
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 setFilter(f: Filter) { filter = f; langFilter = null; }
|
||||
|
||||
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={() => setFilter(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">
|
||||
<Plus size={14} weight="light" />
|
||||
</button>
|
||||
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
|
||||
<GitBranch 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 availableLangs.length > 1}
|
||||
<div class="lang-bar">
|
||||
<button class="lang-pill" class:active={langFilter === null} onclick={() => langFilter = null}>All</button>
|
||||
{#each availableLangs as lang}
|
||||
<button class="lang-pill" class:active={langFilter === lang} onclick={() => langFilter = langFilter === lang ? null : lang}>{lang.toUpperCase()}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
<Thumbnail src={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; }
|
||||
|
||||
.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); }
|
||||
|
||||
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
|
||||
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
|
||||
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.lang-pill.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.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); }
|
||||
:global(.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,265 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { gql } from "../../lib/client";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
|
||||
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga, Source, Category } 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);
|
||||
let categories: Category[] = $state([]);
|
||||
let catsLoaded = false;
|
||||
|
||||
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) && !shouldHideNsfw(m, store.settings));
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
|
||||
});
|
||||
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 openCtx(e: MouseEvent, m: Manga) {
|
||||
e.preventDefault();
|
||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||
if (!catsLoaded) {
|
||||
catsLoaded = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
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) },
|
||||
...(categories.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...categories.map((cat): MenuEntry => ({
|
||||
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
|
||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (!name?.trim()) return;
|
||||
const res = await gql<{ createCategory: { category: Category } }>(
|
||||
CREATE_CATEGORY,
|
||||
{ name: name.trim() }
|
||||
).catch(console.error);
|
||||
if (res) {
|
||||
const cat = (res as any).createCategory.category;
|
||||
categories = [...categories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||
}
|
||||
}},
|
||||
];
|
||||
}
|
||||
|
||||
$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.stopPropagation(); openCtx(e, m); }}>
|
||||
<div class="cover-wrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||
{#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 :global(.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); }
|
||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||
.card-skeleton { padding: 0; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
.show-more-cell { grid-column: 1/-1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
|
||||
.show-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.show-more-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -1,152 +0,0 @@
|
||||
.root {
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
|
||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 5px 28px 5px 26px;
|
||||
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.searchClear {
|
||||
position: absolute; right: 7px;
|
||||
color: var(--text-faint); font-size: 14px; line-height: 1;
|
||||
background: none; border: none; cursor: pointer; padding: 2px;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.searchClear:hover { color: var(--text-muted); }
|
||||
|
||||
.clearBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
|
||||
.statsBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.statItem {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.statVal {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--accent-fg);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.statDivider {
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
||||
|
||||
.group { margin-bottom: var(--sp-5); }
|
||||
.groupLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md);
|
||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.row:hover .playIcon { opacity: 1; }
|
||||
|
||||
/* Thumb with session count badge */
|
||||
.thumbWrap { position: relative; flex-shrink: 0; }
|
||||
.thumb {
|
||||
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
||||
object-fit: cover; display: block; background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.sessionBadge {
|
||||
position: absolute; bottom: -4px; right: -6px;
|
||||
background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 1px 4px; border-radius: 6px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||
.mangaTitle {
|
||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.chapterName {
|
||||
font-size: var(--text-sm); color: var(--text-muted);
|
||||
display: flex; align-items: center; gap: var(--sp-1); min-width: 0;
|
||||
}
|
||||
.chapterRange {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
color: var(--text-muted); font-size: var(--text-sm);
|
||||
}
|
||||
.rangeSep {
|
||||
color: var(--text-faint); font-size: 10px; flex-shrink: 0;
|
||||
}
|
||||
.pageBadge {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||
}
|
||||
.time {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.playIcon {
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
opacity: 0; transition: opacity var(--t-base);
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
||||
}
|
||||
.emptyIcon { color: var(--text-faint); }
|
||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||
@@ -1,244 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "@phosphor-icons/react";
|
||||
import { thumbUrl } from "../../lib/client";
|
||||
import { useStore, type HistoryEntry } from "../../store";
|
||||
import s from "./History.module.css";
|
||||
|
||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "Just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function dayLabel(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return "Today";
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
// Estimate reading time: ~8 seconds per page, counted from chapter entries
|
||||
// Each unique chapter read ≈ pageCount pages (fallback 30 if unknown)
|
||||
function formatReadTime(minutes: number): string {
|
||||
if (minutes < 1) return "< 1 min";
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
// ── Session grouping ──────────────────────────────────────────────────────────
|
||||
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
export interface ReadingSession {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
latestChapterId: number;
|
||||
latestChapterName: string;
|
||||
latestPageNumber: number;
|
||||
firstChapterName: string;
|
||||
chapterCount: number;
|
||||
readAt: number;
|
||||
}
|
||||
|
||||
function buildSessions(entries: HistoryEntry[]): ReadingSession[] {
|
||||
if (!entries.length) return [];
|
||||
const sessions: ReadingSession[] = [];
|
||||
let i = 0;
|
||||
while (i < entries.length) {
|
||||
const anchor = entries[i];
|
||||
const group: HistoryEntry[] = [anchor];
|
||||
let j = i + 1;
|
||||
while (j < entries.length) {
|
||||
const next = entries[j];
|
||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
||||
group.push(next);
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const latest = group[0];
|
||||
const oldest = group[group.length - 1];
|
||||
sessions.push({
|
||||
mangaId: latest.mangaId,
|
||||
mangaTitle: latest.mangaTitle,
|
||||
thumbnailUrl: latest.thumbnailUrl,
|
||||
latestChapterId: latest.chapterId,
|
||||
latestChapterName: latest.chapterName,
|
||||
latestPageNumber: latest.pageNumber,
|
||||
firstChapterName: oldest.chapterName,
|
||||
chapterCount: group.length,
|
||||
readAt: latest.readAt,
|
||||
});
|
||||
i = j;
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function groupSessionsByDay(sessions: ReadingSession[]): { label: string; items: ReadingSession[] }[] {
|
||||
const groups = new Map<string, ReadingSession[]>();
|
||||
for (const sess of sessions) {
|
||||
const label = dayLabel(sess.readAt);
|
||||
if (!groups.has(label)) groups.set(label, []);
|
||||
groups.get(label)!.push(sess);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function History() {
|
||||
const history = useStore((s) => s.history);
|
||||
const clearHistory = useStore((s) => s.clearHistory);
|
||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||
const openReader = useStore((s) => s.openReader);
|
||||
const activeChapterList = useStore((s) => s.activeChapterList);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return history;
|
||||
return history.filter(
|
||||
(e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q)
|
||||
);
|
||||
}, [history, search]);
|
||||
|
||||
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
||||
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
if (!history.length) return null;
|
||||
// Unique chapters read
|
||||
const uniqueChapters = new Set(history.map((e) => e.chapterId)).size;
|
||||
// Unique manga read
|
||||
const uniqueManga = new Set(history.map((e) => e.mangaId)).size;
|
||||
// Estimated read time: average ~45 pages/chapter at ~6s/page = ~4.5 min/chapter
|
||||
const estimatedMinutes = Math.round(uniqueChapters * 4.5);
|
||||
return { uniqueChapters, uniqueManga, estimatedMinutes };
|
||||
}, [history]);
|
||||
|
||||
function resumeReading(session: ReadingSession) {
|
||||
// If the chapter list is available in store (user already visited this manga),
|
||||
// open the reader directly for a snappier experience
|
||||
const chapterInList = activeChapterList.find((c) => c.id === session.latestChapterId);
|
||||
if (chapterInList && activeChapterList.length > 0) {
|
||||
openReader(chapterInList, activeChapterList);
|
||||
} else {
|
||||
// Fall back to opening SeriesDetail — it will show the continue button
|
||||
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>History</h1>
|
||||
<div className={s.headerRight}>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input className={s.search} placeholder="Search history…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
||||
)}
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
||||
<Trash size={14} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className={s.statsBar}>
|
||||
<span className={s.statItem}>
|
||||
<span className={s.statVal}>{stats.uniqueChapters}</span>
|
||||
<span className={s.statLabel}>chapters read</span>
|
||||
</span>
|
||||
<span className={s.statDivider} />
|
||||
<span className={s.statItem}>
|
||||
<span className={s.statVal}>{stats.uniqueManga}</span>
|
||||
<span className={s.statLabel}>series</span>
|
||||
</span>
|
||||
<span className={s.statDivider} />
|
||||
<span className={s.statItem}>
|
||||
<span className={s.statVal}>{formatReadTime(stats.estimatedMinutes)}</span>
|
||||
<span className={s.statLabel}>est. read time</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No reading history yet</p>
|
||||
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<Books size={28} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No results for "{search}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{groups.map(({ label, items }) => (
|
||||
<div key={label} className={s.group}>
|
||||
<p className={s.groupLabel}>{label}</p>
|
||||
{items.map((session) => (
|
||||
<button
|
||||
key={`${session.latestChapterId}-${session.readAt}`}
|
||||
className={s.row}
|
||||
onClick={() => resumeReading(session)}
|
||||
>
|
||||
<div className={s.thumbWrap}>
|
||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} className={s.thumb} />
|
||||
{session.chapterCount > 1 && (
|
||||
<span className={s.sessionBadge}>{session.chapterCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={s.info}>
|
||||
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
||||
<span className={s.chapterName}>
|
||||
{session.chapterCount > 1 ? (
|
||||
<span className={s.chapterRange}>
|
||||
{session.firstChapterName}
|
||||
<span className={s.rangeSep}>→</span>
|
||||
{session.latestChapterName}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{session.latestChapterName}
|
||||
{session.latestPageNumber > 1 && (
|
||||
<span className={s.pageBadge}>p.{session.latestPageNumber}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={s.time}>{timeAgo(session.readAt)}</span>
|
||||
<Play size={12} weight="fill" className={s.playIcon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets, Bell } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { getBlobUrl } from "../../lib/imageCache";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter, clearLibraryUpdates } from "../../store/state.svelte";
|
||||
import type { HistoryEntry } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
import { buildReaderChapterList } from "../../lib/chapterList";
|
||||
|
||||
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; })
|
||||
.catch(console.error)
|
||||
.finally(() => loadingLibrary = false);
|
||||
}
|
||||
|
||||
function resetAndReload() {
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
loadingLibrary = true;
|
||||
heroChapters = [];
|
||||
heroAllChapters = [];
|
||||
heroChaptersFor = null;
|
||||
loadLibrary();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (store.navPage === "home") untrack(() => resetAndReload());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sessionId = store.readerSessionId;
|
||||
if (sessionId === 0) return;
|
||||
untrack(() => resetAndReload());
|
||||
});
|
||||
|
||||
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 heroThumbSrc = $derived(
|
||||
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
|
||||
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
|
||||
);
|
||||
let heroThumb = $state("");
|
||||
$effect(() => {
|
||||
const path = heroThumbSrc;
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (!path) { heroThumb = ""; return; }
|
||||
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
|
||||
// Use tauri-plugin-http backed getBlobUrl which handles auth and bypasses CORS
|
||||
getBlobUrl(thumbUrl(path))
|
||||
.then(url => { heroThumb = url; })
|
||||
.catch(() => { heroThumb = ""; });
|
||||
});
|
||||
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;
|
||||
void store.settings.mangaPrefs?.[id!];
|
||||
if (id) 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 filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
|
||||
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
|
||||
const startIdx = Math.max(0, lastReadIdx);
|
||||
heroChapters = filtered.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);
|
||||
}
|
||||
if (all.length) {
|
||||
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
|
||||
store.activeManga = manga;
|
||||
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
|
||||
const target = list.find(c => c.id === chapter.id) ?? list[0];
|
||||
if (target) openReader(target, list);
|
||||
}
|
||||
} 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 raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
|
||||
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
|
||||
if (ch) {
|
||||
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
||||
openReader(ch, list);
|
||||
}
|
||||
} 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 raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
||||
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
||||
if (ch) {
|
||||
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||
openReader(ch, list);
|
||||
} 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 recentHistory = $derived(store.history.slice(0, 6));
|
||||
const stats = $derived(store.readingStats);
|
||||
const libraryUpdates = $derived(store.libraryUpdates.slice(0, 7));
|
||||
const lastRefresh = $derived(store.lastLibraryRefresh);
|
||||
|
||||
function timeAgoRefresh(ts: number): string {
|
||||
if (!ts) return "";
|
||||
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`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
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)}>
|
||||
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="activity-thumb" />
|
||||
<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"><Bell size={10} weight="bold" /> Updates
|
||||
{#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
|
||||
</span>
|
||||
{#if libraryUpdates.length > 0}
|
||||
<button class="see-all" onclick={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}>Clear <ArrowRight size={9} weight="bold" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if libraryUpdates.length > 0}
|
||||
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
||||
{#each libraryUpdates as u (u.mangaId)}
|
||||
{@const m = libraryManga.find(x => x.id === u.mangaId)}
|
||||
<button class="mini-card" onclick={() => { if (m) store.previewManga = m; }}>
|
||||
<div class="mini-cover-wrap">
|
||||
<Thumbnail src={u.thumbnailUrl} alt={u.mangaTitle} class="mini-cover" />
|
||||
<div class="mini-gradient"></div>
|
||||
<div class="mini-footer">
|
||||
<p class="mini-card-title">{u.mangaTitle}</p>
|
||||
<p class="mini-card-source">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="bottom-empty">{lastRefresh ? "No new chapters found" : "Check for updates in the library"}</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"><Bell size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{libraryUpdates.length}</span><span class="stat-label">New updates</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)}>
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="picker-thumb" />
|
||||
<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; }
|
||||
:global(.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; }
|
||||
.refresh-age { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
|
||||
.mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
|
||||
.mini-row::-webkit-scrollbar { display: none; }
|
||||
|
||||
.mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.mini-card:hover :global(.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); }
|
||||
:global(.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); }
|
||||
:global(.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); }
|
||||