mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d3d76fa6d | |||
| fec0e5d3f6 | |||
| f866d4d0e9 | |||
| ac1c0520c5 | |||
| fff6bde8ad | |||
| c07fc90fc8 | |||
| 523fb40538 | |||
| fb82abaf21 | |||
| 0a4108218d | |||
| 7b61f85833 | |||
| cd2d79f80c | |||
| edf2af8618 | |||
| 55d1431673 | |||
| 11247a69fe | |||
| dc6db4dd98 | |||
| 5c586f39a2 | |||
| f21110dbdb | |||
| dfabb82237 |
@@ -0,0 +1,125 @@
|
|||||||
|
pkgname=moku
|
||||||
|
pkgver=0.2.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/Youwes09/Moku"
|
||||||
|
license=('Apache 2.0')
|
||||||
|
depends=(
|
||||||
|
'webkit2gtk-4.1'
|
||||||
|
'gtk3'
|
||||||
|
'libayatana-appindicator'
|
||||||
|
'java-runtime>=21'
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'rust'
|
||||||
|
'cargo'
|
||||||
|
'nodejs'
|
||||||
|
'pnpm'
|
||||||
|
)
|
||||||
|
source=(
|
||||||
|
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
|
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
||||||
|
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
||||||
|
)
|
||||||
|
sha256sums=('dfd110ae4f11711ce979020ae65b08ab2d0bd51ecc1ba877ba1780ba037357a4'
|
||||||
|
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||||
|
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
cd "Moku-$pkgver"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = false
|
||||||
|
server.initialOpenInBrowserEnabled = false
|
||||||
|
server.systemTrayEnabled = false
|
||||||
|
server.downloadAsCbz = true
|
||||||
|
server.autoDownloadNewChapters = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
server.extensionRepos = []
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
|
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sed -i \
|
||||||
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
unset DISPLAY
|
||||||
|
unset WAYLAND_DISPLAY
|
||||||
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
|
exec /usr/lib/moku/jre/bin/java \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
-Dapple.awt.UIElement=true \
|
||||||
|
-Dsun.java2d.noddraw=true \
|
||||||
|
-Dsun.awt.disablegui=true \
|
||||||
|
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||||
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Desktop entry and icons
|
||||||
|
install -Dm644 packaging/dev.moku.app.desktop \
|
||||||
|
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
||||||
|
install -Dm644 src-tauri/icons/32x32.png \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
||||||
|
install -Dm644 src-tauri/icons/128x128.png \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.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"
|
||||||
|
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
}
|
||||||
@@ -1,22 +1,89 @@
|
|||||||
Todo:
|
Todo:
|
||||||
1. Check all Keybind Toggles
|
3. Explore Manga Upscaler & Other Image Processing
|
||||||
2. Update ReadME with Comprehensive Feature List
|
4. Font Weird on Flatpak, Investigate and Fix
|
||||||
3. Explore Manga Upscaler
|
5. Investigate "egl:failed to create dri2 screen"
|
||||||
4. Add Zoom-Slider for Zoom in Manga Reader
|
|
||||||
|
|
||||||
|
|
||||||
Bugs:
|
Bugs:
|
||||||
2. Fix Auto-scroll between chapters & state when moving chapters (Implemented but not Fluidic)
|
|
||||||
3. Patch Chapters to Grid View
|
-
|
||||||
5. Fix Keybind Toggles
|
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
||||||
|
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
||||||
|
- Add Back after Search & Clear on Search
|
||||||
|
- Add as Package in Nix Flake & Check Later
|
||||||
|
- GenreDrill & GenreFilter pages do not populate completely.
|
||||||
|
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
||||||
|
- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy
|
||||||
|
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
||||||
|
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
||||||
|
|
||||||
|
|
||||||
|
- Fix Mangafire Main Dispatcher Issue
|
||||||
|
|
||||||
|
|
||||||
|
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
||||||
|
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
||||||
|
|
||||||
|
- Clean up Migrate Model to be more initutive
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
1. Frecency based Manga Suggestions
|
- Add PDF Textbook Support
|
||||||
2. Proper Explore Tab
|
- 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
|
||||||
|
-
|
||||||
|
|
||||||
|
|
||||||
Big Revisions:
|
Big Revisions:
|
||||||
|
0. Expand into fully-fledged reader, with modular manga support
|
||||||
1. Anime & Novel Support
|
1. Anime & Novel Support
|
||||||
|
2. Tracker Support
|
||||||
|
3. Cloudflare Bypass Enable Support
|
||||||
|
4. macOS Support (feasible)
|
||||||
|
|
||||||
Test:
|
|
||||||
1. URL & Extension Additions
|
|
||||||
|
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
|
||||||
Executable
+184
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-scripts/release.sh
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Usage:
|
||||||
|
# ./build-scripts/release.sh 0.2.0 — full release (AUR + Flatpak)
|
||||||
|
# ./build-scripts/release.sh 0.2.0 --aur — AUR bin package only
|
||||||
|
# ./build-scripts/release.sh 0.2.0 --flatpak — Flatpak sources + bundle only
|
||||||
|
#
|
||||||
|
# Requires: nix, podman (for AUR .SRCINFO generation in Arch container)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Colour helpers ─────────────────────────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${CYAN} →${RESET} $*"; }
|
||||||
|
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW} ⚠${RESET} $*"; }
|
||||||
|
die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
||||||
|
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
||||||
|
|
||||||
|
# ── Args ───────────────────────────────────────────────────────────────────────
|
||||||
|
[[ $# -lt 1 ]] && die "Usage: $0 <version> [--aur|--flatpak]"
|
||||||
|
|
||||||
|
VERSION="$1"
|
||||||
|
MODE="${2:-all}"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
AUR_DIR="${REPO_ROOT}/../moku-bin"
|
||||||
|
TARBALL="moku-${VERSION}-x86_64.tar.gz"
|
||||||
|
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
|
||||||
|
|
||||||
|
# ── Sanity checks ──────────────────────────────────────────────────────────────
|
||||||
|
section "Pre-flight"
|
||||||
|
command -v nix &>/dev/null || die "nix not found"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
||||||
|
command -v podman &>/dev/null || die "podman not found — needed for Arch container (makepkg)"
|
||||||
|
[[ -d "$AUR_DIR" ]] || die "AUR dir not found at $AUR_DIR\nClone it first:\n git clone ssh://aur@aur.archlinux.org/moku-bin.git ../moku-bin"
|
||||||
|
[[ -f "${AUR_DIR}/PKGBUILD" ]] || die "PKGBUILD not found in $AUR_DIR"
|
||||||
|
fi
|
||||||
|
success "OK"
|
||||||
|
|
||||||
|
# ── Bump versions ──────────────────────────────────────────────────────────────
|
||||||
|
section "Bumping version → ${VERSION}"
|
||||||
|
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \
|
||||||
|
"${REPO_ROOT}/src-tauri/tauri.conf.json"
|
||||||
|
success "tauri.conf.json → ${VERSION}"
|
||||||
|
|
||||||
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"${VERSION}\"/" \
|
||||||
|
"${REPO_ROOT}/src-tauri/Cargo.toml"
|
||||||
|
success "Cargo.toml → ${VERSION}"
|
||||||
|
|
||||||
|
# ── Build frontend ─────────────────────────────────────────────────────────────
|
||||||
|
section "Building frontend"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
nix develop --command pnpm install --frozen-lockfile
|
||||||
|
nix develop --command pnpm build
|
||||||
|
success "Frontend built → dist/"
|
||||||
|
|
||||||
|
# ── Build Rust binary ──────────────────────────────────────────────────────────
|
||||||
|
section "Building Rust binary"
|
||||||
|
nix develop --command cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
|
|
||||||
|
BINARY="${REPO_ROOT}/src-tauri/target/release/moku"
|
||||||
|
[[ -f "$BINARY" ]] || die "Binary not found: $BINARY"
|
||||||
|
success "Binary → $BINARY"
|
||||||
|
|
||||||
|
# ── Flatpak ────────────────────────────────────────────────────────────────────
|
||||||
|
if [[ "$MODE" == "all" || "$MODE" == "--flatpak" ]]; then
|
||||||
|
section "Regenerating cargo-sources.json"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
nix-shell \
|
||||||
|
-p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" \
|
||||||
|
--run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
||||||
|
success "cargo-sources.json updated"
|
||||||
|
|
||||||
|
section "Rebuilding frontend-dist.tar.gz"
|
||||||
|
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||||
|
FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
||||||
|
success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}"
|
||||||
|
|
||||||
|
# Patch the sha256 in dev.moku.app.yml automatically via a temp script
|
||||||
|
PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py)
|
||||||
|
cat > "$PATCH_SCRIPT" << PYEOF
|
||||||
|
import re, sys
|
||||||
|
|
||||||
|
path = "${FLATPAK_MANIFEST}"
|
||||||
|
new_sha = "${FRONTEND_SHA}"
|
||||||
|
text = open(path).read()
|
||||||
|
|
||||||
|
pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+'
|
||||||
|
replacement = r'\g<1>' + new_sha
|
||||||
|
updated, n = re.subn(pattern, replacement, text)
|
||||||
|
if n == 0:
|
||||||
|
sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml")
|
||||||
|
open(path, 'w').write(updated)
|
||||||
|
PYEOF
|
||||||
|
nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'"
|
||||||
|
rm -f "$PATCH_SCRIPT"
|
||||||
|
success "dev.moku.app.yml sha256 updated"
|
||||||
|
|
||||||
|
section "Building Flatpak bundle"
|
||||||
|
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
||||||
|
|
||||||
|
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \
|
||||||
|
flatpak-builder \
|
||||||
|
--repo="${REPO_ROOT}/repo" \
|
||||||
|
--force-clean \
|
||||||
|
"${REPO_ROOT}/build-dir" \
|
||||||
|
"$FLATPAK_MANIFEST"
|
||||||
|
|
||||||
|
flatpak build-bundle \
|
||||||
|
"${REPO_ROOT}/repo" \
|
||||||
|
"${REPO_ROOT}/moku.flatpak" \
|
||||||
|
dev.moku.app
|
||||||
|
|
||||||
|
# Clean up intermediate build artefacts — keep only moku.flatpak
|
||||||
|
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
||||||
|
success "moku.flatpak created"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── AUR tarball + PKGBUILD ─────────────────────────────────────────────────────
|
||||||
|
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
||||||
|
section "Assembling release tarball"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
STAGE="release-${VERSION}"
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
|
||||||
|
install -Dm755 "$BINARY" "${STAGE}/usr/bin/moku"
|
||||||
|
install -Dm644 packaging/dev.moku.app.desktop "${STAGE}/usr/share/applications/dev.moku.app.desktop"
|
||||||
|
install -Dm644 src-tauri/icons/32x32.png "${STAGE}/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
||||||
|
install -Dm644 src-tauri/icons/128x128.png "${STAGE}/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
||||||
|
install -Dm644 "src-tauri/icons/128x128@2x.png" "${STAGE}/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
||||||
|
install -Dm644 packaging/dev.moku.app.metainfo.xml "${STAGE}/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
||||||
|
|
||||||
|
tar -czf "$TARBALL" "$STAGE/"
|
||||||
|
AUR_SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
success "Tarball: ${TARBALL} sha256: ${AUR_SHA}"
|
||||||
|
|
||||||
|
section "Patching PKGBUILD"
|
||||||
|
PKGBUILD="${AUR_DIR}/PKGBUILD"
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
||||||
|
sed -i "s/sha256sums=('[^']*')/sha256sums=('${AUR_SHA}')/" "$PKGBUILD"
|
||||||
|
success "PKGBUILD patched"
|
||||||
|
|
||||||
|
# Tarball is only needed for the GitHub upload — remind user then it can go
|
||||||
|
info "Tarball kept at ${REPO_ROOT}/${TARBALL} — upload it to GitHub, then it can be deleted"
|
||||||
|
|
||||||
|
section "Generating .SRCINFO (Arch container)"
|
||||||
|
# Mount only the AUR dir into a throwaway Arch container and run makepkg
|
||||||
|
podman run --rm \
|
||||||
|
--volume "${AUR_DIR}:/aur:z" \
|
||||||
|
--workdir /aur \
|
||||||
|
archlinux:latest \
|
||||||
|
bash -c "
|
||||||
|
pacman -Sy --noconfirm pacman >/dev/null 2>&1
|
||||||
|
source PKGBUILD
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
"
|
||||||
|
success ".SRCINFO generated"
|
||||||
|
|
||||||
|
section "Next steps"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}1. Upload tarball to GitHub:${RESET}"
|
||||||
|
echo -e " ${CYAN}gh release create v${VERSION} '${REPO_ROOT}/${TARBALL}' --title 'v${VERSION}' --generate-notes${RESET}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}2. Push AUR:${RESET}"
|
||||||
|
echo -e " ${CYAN}cd ${AUR_DIR}${RESET}"
|
||||||
|
echo -e " ${CYAN}git add PKGBUILD .SRCINFO${RESET}"
|
||||||
|
echo -e " ${CYAN}git commit -m 'Update to ${VERSION}'${RESET}"
|
||||||
|
echo -e " ${CYAN}git push origin master${RESET}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}3. Clean up:${RESET}"
|
||||||
|
echo -e " ${CYAN}rm -f ${REPO_ROOT}/${TARBALL}${RESET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
success "v${VERSION} ready"
|
||||||
+1
-1
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 58b475fccdd41807dfd5e55e236925b9d88ed1df49367fb93f7e463d7ae204d2
|
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 1;
|
||||||
hash = "sha256-2Hdzsjwbb+CKiRn/nGHwLeysKvpvEhd5C213YgWmOSU=";
|
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
buildPhase = "pnpm build";
|
||||||
@@ -118,7 +118,6 @@
|
|||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r ${frontend} ../dist
|
cp -r ${frontend} ../dist
|
||||||
'';
|
'';
|
||||||
WEBKIT_DISABLE_COMPOSITING_MODE = "1";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
@@ -133,7 +132,9 @@
|
|||||||
pkgs.gtk3
|
pkgs.gtk3
|
||||||
]}" \
|
]}" \
|
||||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}"
|
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
||||||
|
--set GDK_BACKEND wayland \
|
||||||
|
--set WEBKIT_FORCE_SANDBOX 0
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,7 +158,6 @@
|
|||||||
xdg-utils
|
xdg-utils
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
|
||||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
export NO_STRIP=true
|
export NO_STRIP=true
|
||||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "~2",
|
"@tauri-apps/plugin-shell": "~2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Binary file not shown.
Generated
+20
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@radix-ui/react-tooltip':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: ^1.2.8
|
specifier: ^1.2.8
|
||||||
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.13.18
|
||||||
|
version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
@@ -837,6 +840,15 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.13.18':
|
||||||
|
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.13.18':
|
||||||
|
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
|
||||||
|
|
||||||
'@tauri-apps/api@2.10.1':
|
'@tauri-apps/api@2.10.1':
|
||||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||||
|
|
||||||
@@ -2107,6 +2119,14 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.58.0':
|
'@rollup/rollup-win32-x64-msvc@4.58.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.13.18
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.13.18': {}
|
||||||
|
|
||||||
'@tauri-apps/api@2.10.1': {}
|
'@tauri-apps/api@2.10.1': {}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.10.0':
|
'@tauri-apps/cli-darwin-arm64@2.10.0':
|
||||||
|
|||||||
Generated
+1
-1
@@ -1797,7 +1797,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"nix",
|
"nix",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
+66
-23
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use nix::sys::statvfs::statvfs;
|
use nix::sys::statvfs::statvfs;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::Manager;
|
use tauri::{Manager, WindowEvent};
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -51,9 +51,6 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
};
|
};
|
||||||
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// f_frsize is the fundamental block size used for block counts.
|
|
||||||
// f_bsize (block_size()) is just the preferred I/O size and must not be
|
|
||||||
// used with blocks()/blocks_free() — that gives wildly wrong numbers.
|
|
||||||
let frsize = vfs.fragment_size() as u64;
|
let frsize = vfs.fragment_size() as u64;
|
||||||
let total_bytes = vfs.blocks() * frsize;
|
let total_bytes = vfs.blocks() * frsize;
|
||||||
let free_bytes = vfs.blocks_available() * frsize;
|
let free_bytes = vfs.blocks_available() * frsize;
|
||||||
@@ -66,31 +63,77 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the true OS-level scale factor for the main window.
|
||||||
|
/// This reads directly from the underlying winit window handle, bypassing
|
||||||
|
/// whatever value WebKitGTK happens to report to JS via window.devicePixelRatio.
|
||||||
|
/// This is the only reliable way to get the correct DPR in all launch
|
||||||
|
/// environments — tauri dev, nix run, flatpak, etc.
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_scale_factor(window: tauri::Window) -> f64 {
|
||||||
|
window.scale_factor().unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
let mut guard = state.0.lock().unwrap();
|
||||||
|
if let Some(child) = guard.take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
println!("Killed tracked server child.");
|
||||||
|
}
|
||||||
|
let _ = std::process::Command::new("pkill")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("tachidesk")
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
{
|
||||||
|
let guard = state.0.lock().unwrap();
|
||||||
|
if guard.is_some() {
|
||||||
|
println!("Server already running, skipping spawn.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shell = app.shell();
|
||||||
|
match shell.command(&binary).spawn() {
|
||||||
|
Ok((_rx, child)) => {
|
||||||
|
println!("Spawned server: {}", binary);
|
||||||
|
let mut guard = state.0.lock().unwrap();
|
||||||
|
*guard = Some(child);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to spawn {}: {}", binary, e);
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
kill_tachidesk(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![get_storage_info])
|
.invoke_handler(tauri::generate_handler![
|
||||||
.setup(|app| {
|
get_storage_info,
|
||||||
let shell = app.shell();
|
spawn_server,
|
||||||
let app_handle = app.handle().clone();
|
kill_server,
|
||||||
|
get_scale_factor,
|
||||||
let status = shell.command("tachidesk-server").spawn();
|
])
|
||||||
|
.setup(|_app| Ok(()))
|
||||||
match status {
|
.on_window_event(|window, event| {
|
||||||
Ok((_rx, child)) => {
|
if let WindowEvent::Destroyed = event {
|
||||||
println!("Tachidesk server process spawned successfully.");
|
kill_tachidesk(window.app_handle());
|
||||||
let state = app_handle.state::<ServerState>();
|
|
||||||
let mut guard = state.0.lock().unwrap();
|
|
||||||
*guard = Some(child);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to spawn Tachidesk server: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "dev.moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+152
-13
@@ -1,54 +1,193 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { gql } from "./lib/client";
|
||||||
|
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
import { useStore } from "./store";
|
import { useStore } from "./store";
|
||||||
import Layout from "./components/layout/Layout";
|
import Layout from "./components/layout/Layout";
|
||||||
import Reader from "./components/pages/Reader";
|
import Reader from "./components/pages/Reader";
|
||||||
import Settings from "./components/settings/Settings";
|
import Settings from "./components/settings/Settings";
|
||||||
|
import MangaPreview from "./components/explore/MangaPreview";
|
||||||
import TitleBar from "./components/layout/TitleBar";
|
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";
|
import s from "./App.module.css";
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 30;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const activeChapter = useStore((s) => s.activeChapter);
|
const activeChapter = useStore((s) => s.activeChapter);
|
||||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
const settingsOpen = useStore((s) => s.settingsOpen);
|
||||||
const settings = useStore((s) => s.settings);
|
const settings = useStore((s) => s.settings);
|
||||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
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(() => {
|
useEffect(() => {
|
||||||
document.documentElement.style.zoom = `${settings.uiScale}%`;
|
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]);
|
}, [settings.uiScale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevent = (e: MouseEvent) => e.preventDefault();
|
const theme = settings.theme ?? "dark";
|
||||||
document.addEventListener("contextmenu", prevent);
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
return () => document.removeEventListener("contextmenu", prevent);
|
}, [settings.theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const p = (e: MouseEvent) => e.preventDefault();
|
||||||
|
document.addEventListener("contextmenu", p);
|
||||||
|
return () => document.removeEventListener("contextmenu", p);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings.autoStartServer) return;
|
if (!settings.autoStartServer) return;
|
||||||
invoke("spawn_server", { binary: settings.serverBinary }).catch((err) =>
|
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
|
||||||
console.warn("Could not start server:", err)
|
console.warn("Could not start server:", err));
|
||||||
);
|
|
||||||
return () => { invoke("kill_server").catch(() => {}); };
|
return () => { invoke("kill_server").catch(() => {}); };
|
||||||
}, [settings.autoStartServer, settings.serverBinary]);
|
}, [settings.autoStartServer, settings.serverBinary]);
|
||||||
|
|
||||||
// Global Tauri download-progress listener — no polling, always current
|
// Poll until server responds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
type DlPayload = { chapterId: number; mangaId: number; progress: number }[];
|
if (serverProbeOk) return;
|
||||||
const unsub = listen<DlPayload>("download-progress", (e) => {
|
let cancelled = false, tries = 0;
|
||||||
setActiveDownloads(e.payload);
|
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),
|
||||||
});
|
});
|
||||||
return () => { unsub.then((fn) => fn()); };
|
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]);
|
}, [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 (
|
return (
|
||||||
<div className={s.root}>
|
<div className={s.root}>
|
||||||
|
{idle && !activeChapter && (
|
||||||
|
<SplashScreen
|
||||||
|
mode="idle"
|
||||||
|
showCards={settings.splashCards ?? true}
|
||||||
|
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!activeChapter && <TitleBar/>}
|
{!activeChapter && <TitleBar/>}
|
||||||
<div className={s.content}>
|
<div className={s.content}>
|
||||||
{activeChapter ? <Reader/> : <Layout/>}
|
{activeChapter ? <Reader/> : <Layout/>}
|
||||||
</div>
|
</div>
|
||||||
{settingsOpen && <Settings/>}
|
{settingsOpen && <Settings/>}
|
||||||
|
<MangaPreview/>
|
||||||
|
<Toaster/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,11 @@
|
|||||||
border: 1px solid var(--border-base);
|
border: 1px solid var(--border-base);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--sp-1);
|
padding: var(--sp-1);
|
||||||
min-width: 180px;
|
min-width: 190px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 16px rgba(0, 0, 0, 0.5),
|
0 0 0 1px rgba(0,0,0,0.08),
|
||||||
0 1px 4px rgba(0, 0, 0, 0.3);
|
0 4px 12px rgba(0,0,0,0.35),
|
||||||
|
0 16px 40px rgba(0,0,0,0.25);
|
||||||
animation: scaleIn 0.1s ease both;
|
animation: scaleIn 0.1s ease both;
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
}
|
}
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px var(--sp-3);
|
padding: 5px var(--sp-2);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -27,29 +28,56 @@
|
|||||||
transition: background var(--t-fast), color var(--t-fast);
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item:hover:not(:disabled) {
|
.item:hover:not(:disabled),
|
||||||
|
.itemFocused:not(:disabled) {
|
||||||
background: var(--bg-overlay);
|
background: var(--bg-overlay);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemDanger { color: var(--color-error); }
|
/* Icon area — fixed-width column so labels align */
|
||||||
.itemDanger:hover:not(:disabled) { background: var(--color-error-bg); color: var(--color-error); }
|
.itemIconWrap {
|
||||||
|
|
||||||
.itemDisabled { opacity: 0.35; cursor: default; }
|
|
||||||
|
|
||||||
.itemIcon {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: inherit;
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-fast);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemLabel { flex: 1; }
|
.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 {
|
.separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border-dim);
|
background: var(--border-dim);
|
||||||
margin: var(--sp-1) var(--sp-2);
|
margin: 3px var(--sp-1);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import s from "./ContextMenu.module.css";
|
import s from "./ContextMenu.module.css";
|
||||||
|
|
||||||
@@ -31,35 +31,61 @@ interface Props {
|
|||||||
|
|
||||||
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
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);
|
||||||
|
|
||||||
// Close on outside click or Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onDown(e: MouseEvent) {
|
function onDown(e: MouseEvent) {
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") onClose();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Use capture so we intercept before other handlers
|
|
||||||
document.addEventListener("mousedown", onDown, true);
|
document.addEventListener("mousedown", onDown, true);
|
||||||
document.addEventListener("keydown", onKey, true);
|
document.addEventListener("keydown", onKey, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", onDown, true);
|
document.removeEventListener("mousedown", onDown, true);
|
||||||
document.removeEventListener("keydown", onKey, true);
|
document.removeEventListener("keydown", onKey, true);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose, focused, actionable, items]);
|
||||||
|
|
||||||
// Adjust position so menu doesn't clip outside viewport.
|
// Focus first item on open
|
||||||
// Compensate for CSS zoom (applied via document.documentElement.style.zoom)
|
useEffect(() => {
|
||||||
// because clientX/Y are pre-zoom pixels while `position:fixed` is post-zoom.
|
if (actionable.length) setFocused(actionable[0]);
|
||||||
const style = useCallback(() => {
|
}, []);
|
||||||
|
|
||||||
|
const getPosition = useCallback(() => {
|
||||||
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
||||||
const scaledX = x / zoom;
|
const scaledX = x / zoom;
|
||||||
const scaledY = y / zoom;
|
const scaledY = y / zoom;
|
||||||
const menuW = 200;
|
const menuW = 200;
|
||||||
const menuH = items.length * 36;
|
const menuH = items.length * 34;
|
||||||
const vw = window.innerWidth / zoom;
|
const vw = window.innerWidth / zoom;
|
||||||
const vh = window.innerHeight / zoom;
|
const vh = window.innerHeight / zoom;
|
||||||
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
||||||
@@ -71,7 +97,7 @@ export default function ContextMenu({ x, y, items, onClose }: Props) {
|
|||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={s.menu}
|
className={s.menu}
|
||||||
style={style()}
|
style={getPosition()}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{items.map((item, i) => {
|
{items.map((item, i) => {
|
||||||
@@ -79,14 +105,24 @@ export default function ContextMenu({ x, y, items, onClose }: Props) {
|
|||||||
return <div key={i} className={s.separator} />;
|
return <div key={i} className={s.separator} />;
|
||||||
}
|
}
|
||||||
const mi = item as ContextMenuItem;
|
const mi = item as ContextMenuItem;
|
||||||
|
const isFocused = focused === i;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
className={[s.item, mi.danger ? s.itemDanger : "", mi.disabled ? s.itemDisabled : ""].join(" ").trim()}
|
className={[
|
||||||
|
s.item,
|
||||||
|
mi.danger ? s.itemDanger : "",
|
||||||
|
mi.disabled ? s.itemDisabled : "",
|
||||||
|
isFocused ? s.itemFocused : "",
|
||||||
|
].filter(Boolean).join(" ")}
|
||||||
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
||||||
|
onMouseEnter={() => !mi.disabled && setFocused(i)}
|
||||||
|
onMouseLeave={() => setFocused(-1)}
|
||||||
disabled={mi.disabled}
|
disabled={mi.disabled}
|
||||||
>
|
>
|
||||||
{mi.icon && <span className={s.itemIcon}>{mi.icon}</span>}
|
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
|
||||||
|
{mi.icon ?? null}
|
||||||
|
</span>
|
||||||
<span className={s.itemLabel}>{mi.label}</span>
|
<span className={s.itemLabel}>{mi.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,9 +34,19 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
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:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
.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 {
|
.statusBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -55,6 +65,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--text-faint);
|
background: var(--text-faint);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusDotActive {
|
.statusDotActive {
|
||||||
@@ -68,6 +79,7 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusCount {
|
.statusCount {
|
||||||
@@ -87,11 +99,14 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border-dim);
|
border: 1px solid var(--border-dim);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
transition: border-color var(--t-fast);
|
transition: border-color var(--t-fast), opacity var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowActive { border-color: var(--accent-dim); }
|
.rowActive { border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
/* Fade out rows being removed */
|
||||||
|
.rowRemoving { opacity: 0.4; pointer-events: none; }
|
||||||
|
|
||||||
/* Thumbnail */
|
/* Thumbnail */
|
||||||
.thumb {
|
.thumb {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
@@ -185,8 +200,8 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
transition: color var(--t-base), background var(--t-base);
|
transition: color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
|
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
.removeBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
.removeBtn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import {
|
import {
|
||||||
@@ -12,36 +12,95 @@ import s from "./DownloadQueue.module.css";
|
|||||||
export default function DownloadQueue() {
|
export default function DownloadQueue() {
|
||||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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);
|
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||||
|
|
||||||
async function poll() {
|
// Apply status to local state + global store.
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
// Completion toasting is handled globally in App.tsx — no duplication here.
|
||||||
.then((d) => {
|
const applyStatus = useCallback((ds: DownloadStatus) => {
|
||||||
setStatus(d.downloadStatus);
|
setStatus(ds);
|
||||||
setActiveDownloads(
|
setActiveDownloads(
|
||||||
d.downloadStatus.queue.map((item) => ({
|
ds.queue.map((item) => ({
|
||||||
chapterId: item.chapter.id,
|
chapterId: item.chapter.id,
|
||||||
mangaId: item.chapter.mangaId,
|
mangaId: item.chapter.mangaId,
|
||||||
progress: item.progress,
|
progress: item.progress,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
})
|
}, [setActiveDownloads]);
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
|
.then((d) => applyStatus(d.downloadStatus))
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poll();
|
poll();
|
||||||
const id = setInterval(poll, 1500);
|
const id = setInterval(poll, 2000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function start() { await gql(START_DOWNLOADER).catch(console.error); poll(); }
|
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||||
async function stop() { await gql(STOP_DOWNLOADER).catch(console.error); poll(); }
|
|
||||||
async function clear() { await gql(CLEAR_DOWNLOADER).catch(console.error); poll(); }
|
async function togglePlay() {
|
||||||
async function dequeue(chapterId: number) {
|
if (togglingPlay) return;
|
||||||
await gql(DEQUEUE_DOWNLOAD, { chapterId }).catch(console.error);
|
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();
|
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 queue = status?.queue ?? [];
|
||||||
@@ -56,24 +115,43 @@ export default function DownloadQueue() {
|
|||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<h1 className={s.heading}>Downloads</h1>
|
<h1 className={s.heading}>Downloads</h1>
|
||||||
<div className={s.headerActions}>
|
<div className={s.headerActions}>
|
||||||
{isRunning ? (
|
<button
|
||||||
<button className={s.iconBtn} onClick={stop} title="Pause">
|
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" />
|
<Pause size={14} weight="fill" />
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<button className={s.iconBtn} onClick={start} disabled={queue.length === 0} title="Resume">
|
|
||||||
<Play size={14} weight="fill" />
|
<Play size={14} weight="fill" />
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<button className={s.iconBtn} onClick={clear} disabled={queue.length === 0} title="Clear queue">
|
</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" />
|
<Trash size={14} weight="regular" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.statusBar}>
|
<div className={s.statusBar}>
|
||||||
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
||||||
<span className={s.statusText}>{isRunning ? "Downloading" : "Paused"}</span>
|
<span className={s.statusText}>
|
||||||
|
{togglingPlay
|
||||||
|
? (isRunning ? "Pausing…" : "Starting…")
|
||||||
|
: isRunning ? "Downloading" : "Paused"}
|
||||||
|
</span>
|
||||||
<span className={s.statusCount}>{queue.length} queued</span>
|
<span className={s.statusCount}>{queue.length} queued</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,11 +168,12 @@ export default function DownloadQueue() {
|
|||||||
const pages = item.chapter.pageCount ?? 0;
|
const pages = item.chapter.pageCount ?? 0;
|
||||||
const done = pagesDownloaded(item.progress, pages);
|
const done = pagesDownloaded(item.progress, pages);
|
||||||
const manga = item.chapter.manga;
|
const manga = item.chapter.manga;
|
||||||
|
const isRemoving = dequeueing.has(item.chapter.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.chapter.id}
|
key={item.chapter.id}
|
||||||
className={[s.row, isActive ? s.rowActive : ""].join(" ").trim()}
|
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
|
||||||
>
|
>
|
||||||
{manga?.thumbnailUrl && (
|
{manga?.thumbnailUrl && (
|
||||||
<div className={s.thumb}>
|
<div className={s.thumb}>
|
||||||
@@ -109,17 +188,13 @@ export default function DownloadQueue() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={s.info}>
|
<div className={s.info}>
|
||||||
{manga?.title && (
|
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
|
||||||
<span className={s.mangaTitle}>{manga.title}</span>
|
|
||||||
)}
|
|
||||||
<span className={s.chapterName}>{item.chapter.name}</span>
|
<span className={s.chapterName}>{item.chapter.name}</span>
|
||||||
|
|
||||||
{pages > 0 && (
|
{pages > 0 && (
|
||||||
<span className={s.pagesLabel}>
|
<span className={s.pagesLabel}>
|
||||||
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className={s.progressWrap}>
|
<div className={s.progressWrap}>
|
||||||
<div
|
<div
|
||||||
@@ -136,9 +211,12 @@ export default function DownloadQueue() {
|
|||||||
<button
|
<button
|
||||||
className={s.removeBtn}
|
className={s.removeBtn}
|
||||||
onClick={() => dequeue(item.chapter.id)}
|
onClick={() => dequeue(item.chapter.id)}
|
||||||
|
disabled={isRemoving}
|
||||||
title="Remove from queue"
|
title="Remove from queue"
|
||||||
>
|
>
|
||||||
<X size={12} weight="light" />
|
{isRemoving
|
||||||
|
? <CircleNotch size={11} weight="light" className="anim-spin" />
|
||||||
|
: <X size={12} weight="light" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header / Tab switcher ───────────────────────────────────────────────── */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-base), color var(--t-base);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* Source picker */
|
||||||
|
.sourcePicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourcePickerLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceSelect {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceSelect:focus { border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--sp-5) 0 var(--sp-6);
|
||||||
|
will-change: scroll-position;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section ─────────────────────────────────────────────────────────────── */
|
||||||
|
.section {
|
||||||
|
margin-bottom: var(--sp-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--sp-6) var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seeAll {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seeAll:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Horizontal scroll row ───────────────────────────────────────────────── */
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 0 var(--sp-6);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* ── Card (shared by all rows) ───────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 110px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .cover { filter: brightness(1.06); }
|
||||||
|
.card:hover .title { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.coverWrap {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
will-change: filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inLibraryBadge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--sp-1);
|
||||||
|
left: var(--sp-1);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressFill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-fg);
|
||||||
|
border-radius: 0 2px 0 0;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 2px;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost card — invisible placeholder to fill row trailing space */
|
||||||
|
.ghostCard {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 110px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
||||||
|
.skeletonRow {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 0 var(--sp-6);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardSkeleton { flex-shrink: 0; width: 110px; }
|
||||||
|
|
||||||
|
.coverSkeleton {
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleSkeleton {
|
||||||
|
height: 11px;
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Genre drill-down grid ───────────────────────────────────────────────── */
|
||||||
|
.drillRoot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.drillTitle {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
padding: var(--sp-5) var(--sp-6);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
align-content: start;
|
||||||
|
will-change: scroll-position;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillCard {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillCard:hover .cover { filter: brightness(1.06); }
|
||||||
|
.drillCard:hover .title { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-8) var(--sp-6);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyHint {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── No source state ─────────────────────────────────────────────────────── */
|
||||||
|
.noSource {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
/* ── Explore More end-cap card ───────────────────────────────────────────── */
|
||||||
|
.exploreMoreCard {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 110px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.exploreMoreCard:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
|
||||||
|
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.exploreMoreInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploreMoreIcon {
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploreMoreLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploreMoreGenre {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.6;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.back:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingHint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid fills entire remaining height, no show-more needed */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
align-content: start;
|
||||||
|
/* Smooth GPU-accelerated scrolling */
|
||||||
|
will-change: scroll-position;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.card:hover .cover { filter: brightness(1.06); }
|
||||||
|
.card:hover .cardTitle { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.coverWrap {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
/* Solid bg shown while image fades in — matches skeleton color */
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
will-change: filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inLibraryBadge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--sp-1);
|
||||||
|
left: var(--sp-1);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeletons */
|
||||||
|
.cardSkeleton { padding: 0; }
|
||||||
|
.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); }
|
||||||
|
.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultCount {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show more — spans full grid width */
|
||||||
|
.showMoreCell {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-2) 0 var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showMoreBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 7px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.showMoreBtn:hover:not(:disabled) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
.showMoreBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
/* ── Animations ──────────────────────────────────────────────────────────── */
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||||
|
|
||||||
|
/* ── Backdrop ────────────────────────────────────────────────────────────── */
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.72);
|
||||||
|
z-index: var(--z-settings);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: fadeIn 0.12s ease both;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal shell ─────────────────────────────────────────────────────────── */
|
||||||
|
.modal {
|
||||||
|
width: min(800px, calc(100vw - 48px));
|
||||||
|
height: min(560px, calc(100vh - 80px));
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: scaleIn 0.16s ease both;
|
||||||
|
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cover column ────────────────────────────────────────────────────────── */
|
||||||
|
.coverCol {
|
||||||
|
width: 190px; flex-shrink: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: var(--sp-5) var(--sp-4) var(--sp-4);
|
||||||
|
gap: var(--sp-3);
|
||||||
|
overflow-y: auto; overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.coverCol::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.coverWrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%; aspect-ratio: 2 / 3; object-fit: cover;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverSpinner {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverActions {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cover action buttons ────────────────────────────────────────────────── */
|
||||||
|
.actionBtn {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: var(--sp-2);
|
||||||
|
width: 100%; padding: 7px var(--sp-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: none; color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||||
|
.actionBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.actionBtnActive {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.actionBtnLabel {
|
||||||
|
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
||||||
|
.folderWrap { position: relative; width: 100%; }
|
||||||
|
|
||||||
|
.folderMenu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px); left: 0; right: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--sp-1);
|
||||||
|
display: flex; flex-direction: column; gap: 1px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
z-index: 10;
|
||||||
|
animation: scaleIn 0.1s ease both;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folderEmpty {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); padding: var(--sp-2) var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folderItem {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none; border: none; cursor: pointer; text-align: left;
|
||||||
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
.folderItemOn { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||||
|
|
||||||
|
.folderCreateRow {
|
||||||
|
display: flex; gap: var(--sp-1); padding: var(--sp-1);
|
||||||
|
}
|
||||||
|
.folderInput {
|
||||||
|
flex: 1; background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm); padding: 4px 8px;
|
||||||
|
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
outline: none; min-width: 0;
|
||||||
|
}
|
||||||
|
.folderInput:focus { border-color: var(--border-focus); }
|
||||||
|
|
||||||
|
.folderOkBtn {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
padding: 4px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.folderOkBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.folderNewBtn {
|
||||||
|
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); background: none; border: none;
|
||||||
|
cursor: pointer; text-align: left; width: 100%;
|
||||||
|
transition: color var(--t-fast);
|
||||||
|
}
|
||||||
|
.folderNewBtn:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Content column ──────────────────────────────────────────────────────── */
|
||||||
|
.content {
|
||||||
|
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||||
|
.contentHeader {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-lg); font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-primary); letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline {
|
||||||
|
font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skByline {
|
||||||
|
height: 14px; width: 55%;
|
||||||
|
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||||
|
animation: pulse 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint); border: none; background: none;
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||||
|
.contentBody {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: var(--sp-5) var(--sp-6);
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-4);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error banner ────────────────────────────────────────────────────────── */
|
||||||
|
.errorBanner {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--color-warn, #f59e0b);
|
||||||
|
background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent);
|
||||||
|
border-radius: var(--radius-sm); padding: 6px var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton rows ───────────────────────────────────────────────────────── */
|
||||||
|
.skRow {
|
||||||
|
display: flex; gap: var(--sp-2); align-items: center;
|
||||||
|
}
|
||||||
|
.skBadge {
|
||||||
|
height: 20px; width: 54px;
|
||||||
|
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||||
|
animation: pulse 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skDesc {
|
||||||
|
display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0;
|
||||||
|
}
|
||||||
|
.skLine {
|
||||||
|
height: 13px; background: var(--bg-overlay);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: pulse 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges ──────────────────────────────────────────────────────────────── */
|
||||||
|
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||||
|
padding: 3px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised); color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.badgeGreen {
|
||||||
|
background: color-mix(in srgb, #22c55e 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #22c55e 30%, transparent);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.badgeDim { /* default */ }
|
||||||
|
.badgeAccent {
|
||||||
|
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
.badgeUnread {
|
||||||
|
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #f59e0b 30%, transparent);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.badgeNsfw {
|
||||||
|
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chapter box — clearly separated from description ────────────────────── */
|
||||||
|
.chapterBox {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapterLoading {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.chapterLoadingLabel {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapterMeta {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapterLabel {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlAllBtn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 3px 10px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
.dlAllBtn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.progressTrack {
|
||||||
|
height: 3px; background: var(--bg-overlay);
|
||||||
|
border-radius: var(--radius-full); overflow: hidden;
|
||||||
|
}
|
||||||
|
.progressFill {
|
||||||
|
height: 100%; background: var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readBtn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: 8px var(--sp-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||||
|
cursor: pointer; align-self: flex-start;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
}
|
||||||
|
.readBtn:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
/* ── Description block ───────────────────────────────────────────────────── */
|
||||||
|
.descBlock {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||||
|
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
line-height: var(--leading-base);
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.descOpen {
|
||||||
|
display: block; -webkit-line-clamp: unset; overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descToggle {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); background: none; border: none;
|
||||||
|
cursor: pointer; padding: 0; align-self: flex-start;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.descToggle:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Genre tags ──────────────────────────────────────────────────────────── */
|
||||||
|
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.genreTag {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
||||||
|
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised); color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genreTagClickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.genreTagClickable:hover {
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata table ──────────────────────────────────────────────────────── */
|
||||||
|
.metaTable {
|
||||||
|
display: flex; flex-direction: column; gap: 1px;
|
||||||
|
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaRow {
|
||||||
|
display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0;
|
||||||
|
}
|
||||||
|
.metaKey {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase; min-width: 56px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.metaVal {
|
||||||
|
font-size: var(--text-sm); color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
.metaLink {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: var(--text-sm); color: var(--accent-fg);
|
||||||
|
text-decoration: none; transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.metaLink:hover { opacity: 0.75; }
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
X, BookmarkSimple, ArrowSquareOut, Play,
|
||||||
|
CircleNotch, Books, CaretDown, FolderSimplePlus, Folder,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import {
|
||||||
|
GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||||
|
} from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
|
import s from "./MangaPreview.module.css";
|
||||||
|
|
||||||
|
export default function MangaPreview() {
|
||||||
|
const previewManga = useStore((st) => st.previewManga);
|
||||||
|
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||||
|
const setActiveManga = useStore((st) => st.setActiveManga);
|
||||||
|
const setNavPage = useStore((st) => st.setNavPage);
|
||||||
|
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||||
|
const openReader = useStore((st) => st.openReader);
|
||||||
|
const addToast = useStore((st) => st.addToast);
|
||||||
|
const folders = useStore((st) => st.settings.folders);
|
||||||
|
const addFolder = useStore((st) => st.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||||
|
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
||||||
|
|
||||||
|
const [manga, setManga] = useState<Manga | null>(null);
|
||||||
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||||
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
const [loadingChapters, setLoadingChapters] = useState(false);
|
||||||
|
const [togglingLib, setTogglingLib] = useState(false);
|
||||||
|
const [descExpanded, setDescExpanded] = useState(false);
|
||||||
|
const [folderOpen, setFolderOpen] = useState(false);
|
||||||
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||||
|
const [queueingAll, setQueueingAll] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
const detailAbort = useRef<AbortController | null>(null);
|
||||||
|
const chapterAbort = useRef<AbortController | null>(null);
|
||||||
|
const folderRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
detailAbort.current?.abort();
|
||||||
|
chapterAbort.current?.abort();
|
||||||
|
setPreviewManga(null);
|
||||||
|
setManga(null);
|
||||||
|
setChapters([]);
|
||||||
|
setDescExpanded(false);
|
||||||
|
setFolderOpen(false);
|
||||||
|
setCreatingFolder(false);
|
||||||
|
setNewFolderName("");
|
||||||
|
setFetchError(null);
|
||||||
|
}, [setPreviewManga]);
|
||||||
|
|
||||||
|
// ── Fetch detail + chapters on open ──────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewManga) return;
|
||||||
|
|
||||||
|
// Abort any in-flight requests from previous manga
|
||||||
|
detailAbort.current?.abort();
|
||||||
|
chapterAbort.current?.abort();
|
||||||
|
|
||||||
|
const dCtrl = new AbortController();
|
||||||
|
const cCtrl = new AbortController();
|
||||||
|
detailAbort.current = dCtrl;
|
||||||
|
chapterAbort.current = cCtrl;
|
||||||
|
|
||||||
|
setManga(null);
|
||||||
|
setChapters([]);
|
||||||
|
setDescExpanded(false);
|
||||||
|
setFetchError(null);
|
||||||
|
setLoadingDetail(true);
|
||||||
|
setLoadingChapters(true);
|
||||||
|
|
||||||
|
const id = previewManga.id;
|
||||||
|
|
||||||
|
// ── Detail fetch strategy ─────────────────────────────────────────────
|
||||||
|
// For source/explore manga we must call FETCH_MANGA (mutation that
|
||||||
|
// hits the source and syncs to the local DB). GET_MANGA only works for
|
||||||
|
// manga already in the local DB with full metadata.
|
||||||
|
//
|
||||||
|
// Fast path: if we already cached a full record, use it directly.
|
||||||
|
// Slow path: always try FETCH_MANGA first — it never fails for valid IDs
|
||||||
|
// and returns the richest data. Fall back to GET_MANGA if it errors.
|
||||||
|
//
|
||||||
|
(async (): Promise<Manga> => {
|
||||||
|
const cacheKey = CACHE_KEYS.MANGA(id);
|
||||||
|
|
||||||
|
// Already have a cached rich record — no network needed
|
||||||
|
if (cache.has(cacheKey)) {
|
||||||
|
return cache.get(cacheKey, () =>
|
||||||
|
Promise.resolve(previewManga as Manga)
|
||||||
|
) as Promise<Manga>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try FETCH_MANGA first — works for all manga regardless of whether
|
||||||
|
// they are in the local DB yet (it fetches from source and syncs).
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchManga: { manga: Manga } }>(
|
||||||
|
FETCH_MANGA, { id }, dCtrl.signal
|
||||||
|
);
|
||||||
|
return d.fetchManga.manga;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") throw e;
|
||||||
|
// FETCH_MANGA failed (e.g. source offline) — fall back to local DB
|
||||||
|
const local = await gql<{ manga: Manga }>(
|
||||||
|
GET_MANGA, { id }, dCtrl.signal
|
||||||
|
).then((d) => d.manga);
|
||||||
|
if (local) return local;
|
||||||
|
throw new Error("Could not load manga details");
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.then((fullManga) => {
|
||||||
|
if (dCtrl.signal.aborted) return;
|
||||||
|
// Cache the rich record so re-opening is instant
|
||||||
|
if (!cache.has(CACHE_KEYS.MANGA(id))) {
|
||||||
|
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
||||||
|
}
|
||||||
|
setManga(fullManga);
|
||||||
|
setLoadingDetail(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
console.error("MangaPreview detail fetch:", e);
|
||||||
|
// Show whatever sparse data we have from previewManga
|
||||||
|
setManga(previewManga as Manga);
|
||||||
|
setFetchError("Could not load full details — showing cached data");
|
||||||
|
setLoadingDetail(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Chapter fetch — local DB first, fall back to source fetch ────────
|
||||||
|
gql<{ chapters: { nodes: Chapter[] } }>(
|
||||||
|
GET_CHAPTERS, { mangaId: id }, cCtrl.signal
|
||||||
|
)
|
||||||
|
.then(async (d) => {
|
||||||
|
if (cCtrl.signal.aborted) return;
|
||||||
|
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
// If no local chapters yet (explore/source manga), fetch from source
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
try {
|
||||||
|
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(
|
||||||
|
FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal
|
||||||
|
);
|
||||||
|
if (!cCtrl.signal.aborted)
|
||||||
|
nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
// Leave nodes empty — not a fatal error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cCtrl.signal.aborted) setChapters(nodes);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||||
|
.finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); });
|
||||||
|
|
||||||
|
return () => { dCtrl.abort(); cCtrl.abort(); };
|
||||||
|
}, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Keyboard close ────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewManga) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [previewManga, close]);
|
||||||
|
|
||||||
|
// ── Folder outside click ──────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!folderOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (folderRef.current && !folderRef.current.contains(e.target as Node)) {
|
||||||
|
setFolderOpen(false); setCreatingFolder(false); setNewFolderName("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [folderOpen]);
|
||||||
|
|
||||||
|
if (!previewManga) return null;
|
||||||
|
|
||||||
|
// Always show title/cover from previewManga immediately; upgrade to fetched manga when ready
|
||||||
|
const displayManga = manga ?? previewManga;
|
||||||
|
const totalCount = chapters.length;
|
||||||
|
const readCount = chapters.filter((c) => c.isRead).length;
|
||||||
|
const unreadCount = totalCount - readCount;
|
||||||
|
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
||||||
|
const bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
|
||||||
|
const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false;
|
||||||
|
|
||||||
|
// Scanlators — deduplicated, non-empty
|
||||||
|
const scanlators = [...new Set(
|
||||||
|
chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim())
|
||||||
|
)];
|
||||||
|
|
||||||
|
// Publication date range from chapter upload dates
|
||||||
|
const uploadDates = chapters
|
||||||
|
.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null)
|
||||||
|
.filter((d): d is number => d !== null && !isNaN(d));
|
||||||
|
const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
|
||||||
|
const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
|
||||||
|
|
||||||
|
function formatDate(d: Date) {
|
||||||
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = displayManga.status
|
||||||
|
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const continueChapter = (() => {
|
||||||
|
if (!chapters.length) return null;
|
||||||
|
const asc = [...chapters];
|
||||||
|
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
|
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
||||||
|
const firstUnread = asc.find((c) => !c.isRead);
|
||||||
|
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
||||||
|
return { ch: asc[0], label: "Read again" };
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function toggleLibrary() {
|
||||||
|
if (!manga) return;
|
||||||
|
setTogglingLib(true);
|
||||||
|
const next = !manga.inLibrary;
|
||||||
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||||
|
const updated = { ...manga, inLibrary: next };
|
||||||
|
setManga(updated);
|
||||||
|
// Update cache so subsequent opens reflect new state
|
||||||
|
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
||||||
|
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
setTogglingLib(false);
|
||||||
|
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAll() {
|
||||||
|
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
setQueueingAll(true);
|
||||||
|
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
||||||
|
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
||||||
|
setQueueingAll(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSeriesDetail() {
|
||||||
|
setActiveManga(displayManga);
|
||||||
|
setNavPage("library");
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFolderCreate() {
|
||||||
|
const name = newFolderName.trim();
|
||||||
|
if (!name || !previewManga) return;
|
||||||
|
const newId = addFolder(name);
|
||||||
|
assignMangaToFolder(newId, previewManga.id);
|
||||||
|
setNewFolderName("");
|
||||||
|
setCreatingFolder(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={s.backdrop}
|
||||||
|
ref={backdropRef}
|
||||||
|
onClick={(e) => { if (e.target === backdropRef.current) close(); }}
|
||||||
|
>
|
||||||
|
<div className={s.modal} role="dialog" aria-label="Manga preview">
|
||||||
|
|
||||||
|
{/* ── Cover column ── */}
|
||||||
|
<div className={s.coverCol}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(previewManga.thumbnailUrl)}
|
||||||
|
alt={displayManga.title}
|
||||||
|
className={s.cover}
|
||||||
|
/>
|
||||||
|
{loadingDetail && (
|
||||||
|
<div className={s.coverSpinner}>
|
||||||
|
<CircleNotch size={18} weight="light" className="anim-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.coverActions}>
|
||||||
|
<button
|
||||||
|
className={[s.actionBtn, inLibrary ? s.actionBtnActive : ""].join(" ")}
|
||||||
|
onClick={toggleLibrary}
|
||||||
|
disabled={togglingLib || loadingDetail}
|
||||||
|
>
|
||||||
|
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
||||||
|
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className={s.actionBtn} onClick={openSeriesDetail}>
|
||||||
|
<Books size={13} weight="light" />
|
||||||
|
Series Detail
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Folder picker */}
|
||||||
|
<div className={s.folderWrap} ref={folderRef}>
|
||||||
|
<button
|
||||||
|
className={[s.actionBtn, assignedFolders.length > 0 ? s.actionBtnFolder : ""].join(" ")}
|
||||||
|
onClick={() => setFolderOpen((p) => !p)}
|
||||||
|
>
|
||||||
|
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
|
||||||
|
<span className={s.actionBtnLabel}>
|
||||||
|
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{folderOpen && (
|
||||||
|
<div className={s.folderMenu}>
|
||||||
|
{folders.length === 0 && !creatingFolder && (
|
||||||
|
<p className={s.folderEmpty}>No folders yet</p>
|
||||||
|
)}
|
||||||
|
{folders.map((f) => {
|
||||||
|
const isIn = f.mangaIds.includes(previewManga.id);
|
||||||
|
return (
|
||||||
|
<button key={f.id}
|
||||||
|
className={[s.folderItem, isIn ? s.folderItemOn : ""].join(" ")}
|
||||||
|
onClick={() => isIn
|
||||||
|
? removeMangaFromFolder(f.id, previewManga.id)
|
||||||
|
: assignMangaToFolder(f.id, previewManga.id)}
|
||||||
|
>
|
||||||
|
<Folder size={12} weight={isIn ? "fill" : "light"} />
|
||||||
|
{isIn ? "✓ " : ""}{f.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className={s.folderDivider} />
|
||||||
|
{creatingFolder ? (
|
||||||
|
<div className={s.folderCreateRow}>
|
||||||
|
<input autoFocus className={s.folderInput} placeholder="Folder name…"
|
||||||
|
value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleFolderCreate();
|
||||||
|
if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className={s.folderOkBtn} onClick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className={s.folderNewBtn} onClick={() => setCreatingFolder(true)}>+ New folder</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content column ── */}
|
||||||
|
<div className={s.content}>
|
||||||
|
|
||||||
|
{/* Header — title visible immediately from previewManga */}
|
||||||
|
<div className={s.contentHeader}>
|
||||||
|
<div className={s.titleBlock}>
|
||||||
|
<h2 className={s.title}>{displayManga.title}</h2>
|
||||||
|
{loadingDetail
|
||||||
|
? <div className={s.skByline} />
|
||||||
|
: (displayManga.author || displayManga.artist)
|
||||||
|
? <p className={s.byline}>
|
||||||
|
{[displayManga.author, displayManga.artist]
|
||||||
|
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}
|
||||||
|
</p>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<button className={s.closeBtn} onClick={close}><X size={15} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable body */}
|
||||||
|
<div className={s.contentBody}>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{fetchError && (
|
||||||
|
<div className={s.errorBanner}>{fetchError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Badges ── */}
|
||||||
|
{loadingDetail ? (
|
||||||
|
<div className={s.skRow}>
|
||||||
|
<div className={s.skBadge} />
|
||||||
|
<div className={s.skBadge} style={{ width: 72 }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.badges}>
|
||||||
|
{statusLabel && (
|
||||||
|
<span className={[s.badge,
|
||||||
|
displayManga.status === "ONGOING" ? s.badgeGreen : s.badgeDim
|
||||||
|
].join(" ")}>{statusLabel}</span>
|
||||||
|
)}
|
||||||
|
{displayManga.source && (
|
||||||
|
<span className={[s.badge, (displayManga.source as any).isNsfw ? s.badgeNsfw : ""].join(" ").trim()}>
|
||||||
|
{displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inLibrary && <span className={[s.badge, s.badgeAccent].join(" ")}>In Library</span>}
|
||||||
|
{!loadingChapters && unreadCount > 0 && (
|
||||||
|
<span className={[s.badge, s.badgeUnread].join(" ")}>{unreadCount} unread</span>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && bookmarkCount > 0 && (
|
||||||
|
<span className={s.badge}>{bookmarkCount} bookmarked</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Chapter section — visually separated box ── */}
|
||||||
|
<div className={s.chapterBox}>
|
||||||
|
{loadingChapters ? (
|
||||||
|
<div className={s.chapterLoading}>
|
||||||
|
<CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
<span className={s.chapterLoadingLabel}>Loading chapters…</span>
|
||||||
|
</div>
|
||||||
|
) : totalCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className={s.chapterMeta}>
|
||||||
|
<span className={s.chapterLabel}>
|
||||||
|
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||||
|
{readCount > 0 && ` · ${readCount} read`}
|
||||||
|
{unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`}
|
||||||
|
{downloadedCount > 0 && ` · ${downloadedCount} dl`}
|
||||||
|
</span>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button className={s.dlAllBtn} onClick={downloadAll} disabled={queueingAll}>
|
||||||
|
{queueingAll && <CircleNotch size={11} weight="light" className="anim-spin" />}
|
||||||
|
{queueingAll ? "Queuing…" : "Download unread"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{readCount > 0 && (
|
||||||
|
<div className={s.progressTrack}>
|
||||||
|
<div className={s.progressFill} style={{ width: `${(readCount / totalCount) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{continueChapter && (
|
||||||
|
<button className={s.readBtn}
|
||||||
|
onClick={() => { openReader(continueChapter.ch, chapters); close(); }}
|
||||||
|
>
|
||||||
|
<Play size={12} weight="fill" />
|
||||||
|
{continueChapter.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : !loadingDetail ? (
|
||||||
|
<span className={s.chapterLabel} style={{ color: "var(--text-faint)" }}>
|
||||||
|
No chapters in local library
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Description — clearly separated from chapter block ── */}
|
||||||
|
{loadingDetail ? (
|
||||||
|
<div className={s.skDesc}>
|
||||||
|
<div className={s.skLine} style={{ width: "100%" }} />
|
||||||
|
<div className={s.skLine} style={{ width: "88%" }} />
|
||||||
|
<div className={s.skLine} style={{ width: "70%" }} />
|
||||||
|
</div>
|
||||||
|
) : displayManga.description ? (
|
||||||
|
<div className={s.descBlock}>
|
||||||
|
<p className={[s.desc, descExpanded ? s.descOpen : ""].join(" ")}>
|
||||||
|
{displayManga.description}
|
||||||
|
</p>
|
||||||
|
{displayManga.description.length > 220 && (
|
||||||
|
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
|
||||||
|
{descExpanded ? "Show less" : "Show more"}
|
||||||
|
<CaretDown size={10} weight="light" style={{
|
||||||
|
transform: descExpanded ? "rotate(180deg)" : "none",
|
||||||
|
transition: "transform 0.15s ease",
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Genre tags ── */}
|
||||||
|
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
|
||||||
|
<div className={s.genres}>
|
||||||
|
{displayManga.genre.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
className={[s.genreTag, s.genreTagClickable].join(" ")}
|
||||||
|
title={`Browse "${g}"`}
|
||||||
|
onClick={() => {
|
||||||
|
setGenreFilter(g);
|
||||||
|
setNavPage("explore");
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Metadata table ── */}
|
||||||
|
{!loadingDetail && (
|
||||||
|
<div className={s.metaTable}>
|
||||||
|
{displayManga.author && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Author</span>
|
||||||
|
<span className={s.metaVal}>{displayManga.author}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayManga.artist && displayManga.artist !== displayManga.author && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Artist</span>
|
||||||
|
<span className={s.metaVal}>{displayManga.artist}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{statusLabel && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Status</span>
|
||||||
|
<span className={s.metaVal}>{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayManga.source && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Source</span>
|
||||||
|
<span className={s.metaVal}>{displayManga.source.displayName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && scanlators.length > 0 && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span>
|
||||||
|
<span className={s.metaVal}>{scanlators.join(", ")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && firstUpload && lastUpload && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Published</span>
|
||||||
|
<span className={s.metaVal}>
|
||||||
|
{firstUpload.getTime() === lastUpload.getTime()
|
||||||
|
? formatDate(firstUpload)
|
||||||
|
: `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && downloadedCount > 0 && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Downloaded</span>
|
||||||
|
<span className={s.metaVal}>{downloadedCount} / {totalCount} chapters</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && bookmarkCount > 0 && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Bookmarks</span>
|
||||||
|
<span className={s.metaVal}>{bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayManga.realUrl && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Link</span>
|
||||||
|
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" className={s.metaLink}>
|
||||||
|
Open <ArrowSquareOut size={11} weight="light" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,7 @@ import Library from "../pages/Library";
|
|||||||
import SeriesDetail from "../pages/SeriesDetail";
|
import SeriesDetail from "../pages/SeriesDetail";
|
||||||
import History from "../pages/History";
|
import History from "../pages/History";
|
||||||
import Search from "../pages/Search";
|
import Search from "../pages/Search";
|
||||||
import SourceList from "../sources/SourceList";
|
import Explore from "../explore/Explore";
|
||||||
import SourceBrowse from "../sources/SourceBrowse";
|
|
||||||
import DownloadQueue from "../downloads/DownloadQueue";
|
import DownloadQueue from "../downloads/DownloadQueue";
|
||||||
import ExtensionList from "../extensions/ExtensionList";
|
import ExtensionList from "../extensions/ExtensionList";
|
||||||
import s from "./Layout.module.css";
|
import s from "./Layout.module.css";
|
||||||
@@ -13,16 +12,15 @@ import s from "./Layout.module.css";
|
|||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const navPage = useStore((s) => s.navPage);
|
const navPage = useStore((s) => s.navPage);
|
||||||
const activeManga = useStore((s) => s.activeManga);
|
const activeManga = useStore((s) => s.activeManga);
|
||||||
const activeSource = useStore((s) => s.activeSource);
|
|
||||||
|
|
||||||
function renderContent() {
|
function renderContent() {
|
||||||
if (navPage === "library" && activeManga) return <SeriesDetail />;
|
if (activeManga) return <SeriesDetail />;
|
||||||
if (navPage === "sources" && activeSource) return <SourceBrowse />;
|
|
||||||
switch (navPage) {
|
switch (navPage) {
|
||||||
case "library": return <Library />;
|
case "library": return <Library />;
|
||||||
case "search": return <Search />;
|
case "search": return <Search />;
|
||||||
case "history": return <History />;
|
case "history": return <History />;
|
||||||
case "sources": return <SourceList />;
|
case "sources": return <Explore />;
|
||||||
|
case "explore": return <Explore />;
|
||||||
case "downloads": return <DownloadQueue />;
|
case "downloads": return <DownloadQueue />;
|
||||||
case "extensions": return <ExtensionList />;
|
case "extensions": return <ExtensionList />;
|
||||||
default: return <Library />;
|
default: return <Library />;
|
||||||
|
|||||||
@@ -17,15 +17,21 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: var(--sp-3);
|
margin-bottom: var(--sp-3);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
/* Explicit reset — prevents browser from injecting a default button background */
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
transition: opacity var(--t-base), transform var(--t-base);
|
transition: opacity var(--t-base), transform var(--t-base);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||||
.logo:active { transform: scale(0.92); }
|
.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 {
|
.logoIcon {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
@@ -58,10 +64,21 @@
|
|||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-faint);
|
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);
|
transition: color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
.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); }
|
.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); }
|
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
@@ -76,6 +93,15 @@
|
|||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-faint);
|
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);
|
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:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||||
|
.settingsBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
@@ -9,7 +9,7 @@ const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
|||||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||||
{ id: "sources", icon: <Compass size={18} weight="light" />, label: "Sources" },
|
{ id: "explore", icon: <Compass size={18} weight="light" />, label: "Explore" },
|
||||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
||||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||||
];
|
];
|
||||||
@@ -20,11 +20,14 @@ export default function Sidebar() {
|
|||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||||
|
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||||
const openSettings = useStore((state) => state.openSettings);
|
const openSettings = useStore((state) => state.openSettings);
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
function navigate(id: NavPage) {
|
||||||
setNavPage(id);
|
setNavPage(id);
|
||||||
if (id !== "sources") setActiveSource(null);
|
setActiveManga(null);
|
||||||
|
setGenreFilter("");
|
||||||
|
if (id !== "explore") setActiveSource(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.toaster {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--sp-5);
|
||||||
|
right: var(--sp-5);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
||||||
|
pointer-events: all;
|
||||||
|
animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kind variants */
|
||||||
|
.toast_success { border-color: var(--accent-dim); }
|
||||||
|
.toast_success .toastIcon { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.toast_error { border-color: var(--color-error); }
|
||||||
|
.toast_error .toastIcon { color: var(--color-error); }
|
||||||
|
|
||||||
|
.toast_download .toastIcon { color: var(--accent-fg); }
|
||||||
|
.toast_info .toastIcon { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.toastIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastBody {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastTitle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastSub {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastClose {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.toastClose:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { CheckCircle, X, WarningCircle, Info, DownloadSimple } from "@phosphor-icons/react";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import s from "./Toaster.module.css";
|
||||||
|
|
||||||
|
export type ToastKind = "success" | "error" | "info" | "download";
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
kind: ToastKind;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
duration?: number; // ms, 0 = persistent
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── icons per kind ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ToastIcon({ kind }: { kind: ToastKind }) {
|
||||||
|
const size = 15;
|
||||||
|
const w = "light" as const;
|
||||||
|
if (kind === "success") return <CheckCircle size={size} weight={w} />;
|
||||||
|
if (kind === "error") return <WarningCircle size={size} weight={w} />;
|
||||||
|
if (kind === "download") return <DownloadSimple size={size} weight={w} />;
|
||||||
|
return <Info size={size} weight={w} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── individual toast ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ToastItem({ toast }: { toast: Toast }) {
|
||||||
|
const dismissToast = useStore((s) => s.dismissToast);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const duration = toast.duration ?? 3500;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration === 0) return;
|
||||||
|
timerRef.current = setTimeout(() => dismissToast(toast.id), duration);
|
||||||
|
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||||
|
}, [toast.id, duration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[s.toast, s[`toast_${toast.kind}`]].join(" ")} role="alert">
|
||||||
|
<span className={s.toastIcon}><ToastIcon kind={toast.kind} /></span>
|
||||||
|
<div className={s.toastBody}>
|
||||||
|
<p className={s.toastTitle}>{toast.title}</p>
|
||||||
|
{toast.body && <p className={s.toastSub}>{toast.body}</p>}
|
||||||
|
</div>
|
||||||
|
<button className={s.toastClose} onClick={() => dismissToast(toast.id)} title="Dismiss">
|
||||||
|
<X size={12} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── toaster container ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Toaster() {
|
||||||
|
const toasts = useStore((s) => s.toasts);
|
||||||
|
|
||||||
|
if (!toasts.length) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={s.toaster} aria-live="polite">
|
||||||
|
{toasts.map((t) => <ToastItem key={t.id} toast={t} />)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,16 +12,25 @@
|
|||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
|
||||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||||
.search {
|
.search {
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
border-radius: var(--radius-md); padding: 5px 10px 5px 26px;
|
border-radius: var(--radius-md); padding: 5px 28px 5px 26px;
|
||||||
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
||||||
transition: border-color var(--t-base);
|
transition: border-color var(--t-base);
|
||||||
}
|
}
|
||||||
.search::placeholder { color: var(--text-faint); }
|
.search::placeholder { color: var(--text-faint); }
|
||||||
.search:focus { border-color: var(--border-strong); }
|
.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 {
|
.clearBtn {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||||
@@ -29,6 +38,44 @@
|
|||||||
}
|
}
|
||||||
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
.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); }
|
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
||||||
|
|
||||||
.group { margin-bottom: var(--sp-5); }
|
.group { margin-bottom: var(--sp-5); }
|
||||||
@@ -47,11 +94,24 @@
|
|||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
.row:hover .playIcon { opacity: 1; }
|
.row:hover .playIcon { opacity: 1; }
|
||||||
|
|
||||||
|
/* Thumb with session count badge */
|
||||||
|
.thumbWrap { position: relative; flex-shrink: 0; }
|
||||||
.thumb {
|
.thumb {
|
||||||
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
||||||
object-fit: cover; flex-shrink: 0; background: var(--bg-raised);
|
object-fit: cover; display: block; background: var(--bg-raised);
|
||||||
border: 1px solid var(--border-dim);
|
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; }
|
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||||
.mangaTitle {
|
.mangaTitle {
|
||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||||
@@ -59,11 +119,19 @@
|
|||||||
}
|
}
|
||||||
.chapterName {
|
.chapterName {
|
||||||
font-size: var(--text-sm); color: var(--text-muted);
|
font-size: var(--text-sm); color: var(--text-muted);
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
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 {
|
.pageBadge {
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.time {
|
.time {
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play } from "@phosphor-icons/react";
|
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "@phosphor-icons/react";
|
||||||
import { thumbUrl } from "../../lib/client";
|
import { thumbUrl } from "../../lib/client";
|
||||||
import { useStore, type HistoryEntry } from "../../store";
|
import { useStore, type HistoryEntry } from "../../store";
|
||||||
import s from "./History.module.css";
|
import s from "./History.module.css";
|
||||||
|
|
||||||
|
// ── Time helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
const diff = Date.now() - ts;
|
const diff = Date.now() - ts;
|
||||||
const m = Math.floor(diff / 60000);
|
const m = Math.floor(diff / 60000);
|
||||||
@@ -16,52 +18,130 @@ function timeAgo(ts: number): string {
|
|||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group entries by day
|
function dayLabel(ts: number): string {
|
||||||
function groupByDay(entries: HistoryEntry[]): { label: string; items: HistoryEntry[] }[] {
|
const d = new Date(ts);
|
||||||
const groups = new Map<string, HistoryEntry[]>();
|
|
||||||
for (const e of entries) {
|
|
||||||
const d = new Date(e.readAt);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let label: string;
|
if (d.toDateString() === now.toDateString()) return "Today";
|
||||||
if (d.toDateString() === now.toDateString()) label = "Today";
|
|
||||||
else {
|
|
||||||
const yesterday = new Date(now);
|
const yesterday = new Date(now);
|
||||||
yesterday.setDate(now.getDate() - 1);
|
yesterday.setDate(now.getDate() - 1);
|
||||||
if (d.toDateString() === yesterday.toDateString()) label = "Yesterday";
|
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
||||||
else label = d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
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, []);
|
if (!groups.has(label)) groups.set(label, []);
|
||||||
groups.get(label)!.push(e);
|
groups.get(label)!.push(sess);
|
||||||
}
|
}
|
||||||
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
const history = useStore((s) => s.history);
|
const history = useStore((s) => s.history);
|
||||||
const clearHistory = useStore((s) => s.clearHistory);
|
const clearHistory = useStore((s) => s.clearHistory);
|
||||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||||
const setNavPage = useStore((s) => s.setNavPage);
|
const openReader = useStore((s) => s.openReader);
|
||||||
|
const activeChapterList = useStore((s) => s.activeChapterList);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const filtered = useMemo(() =>
|
const filtered = useMemo(() => {
|
||||||
search.trim()
|
const q = search.trim().toLowerCase();
|
||||||
? history.filter((e) =>
|
if (!q) return history;
|
||||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
return history.filter(
|
||||||
e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
(e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q)
|
||||||
: history,
|
|
||||||
[history, search]
|
|
||||||
);
|
);
|
||||||
|
}, [history, search]);
|
||||||
|
|
||||||
const groups = useMemo(() => groupByDay(filtered), [filtered]);
|
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
||||||
|
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
||||||
|
|
||||||
function resumeReading(entry: HistoryEntry) {
|
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||||
// Navigate to manga detail — user can continue from there
|
const stats = useMemo(() => {
|
||||||
setActiveManga({
|
if (!history.length) return null;
|
||||||
id: entry.mangaId,
|
// Unique chapters read
|
||||||
title: entry.mangaTitle,
|
const uniqueChapters = new Set(history.map((e) => e.chapterId)).size;
|
||||||
thumbnailUrl: entry.thumbnailUrl,
|
// Unique manga read
|
||||||
} as any);
|
const uniqueManga = new Set(history.map((e) => e.mangaId)).size;
|
||||||
setNavPage("library");
|
// 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 (
|
return (
|
||||||
@@ -73,6 +153,9 @@ export default function History() {
|
|||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||||
<input className={s.search} placeholder="Search history…"
|
<input className={s.search} placeholder="Search history…"
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
{search && (
|
||||||
|
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
||||||
@@ -82,14 +165,34 @@ export default function History() {
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{history.length === 0 ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||||
<p className={s.emptyText}>No reading history yet.</p>
|
<p className={s.emptyText}>No reading history yet</p>
|
||||||
<p className={s.emptyHint}>Chapters you read will appear here.</p>
|
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : sessions.length === 0 ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
|
<Books size={28} weight="light" className={s.emptyIcon} />
|
||||||
<p className={s.emptyText}>No results for "{search}"</p>
|
<p className={s.emptyText}>No results for "{search}"</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -97,20 +200,38 @@ export default function History() {
|
|||||||
{groups.map(({ label, items }) => (
|
{groups.map(({ label, items }) => (
|
||||||
<div key={label} className={s.group}>
|
<div key={label} className={s.group}>
|
||||||
<p className={s.groupLabel}>{label}</p>
|
<p className={s.groupLabel}>{label}</p>
|
||||||
{items.map((entry) => (
|
{items.map((session) => (
|
||||||
<button key={`${entry.chapterId}-${entry.readAt}`}
|
<button
|
||||||
className={s.row} onClick={() => resumeReading(entry)}>
|
key={`${session.latestChapterId}-${session.readAt}`}
|
||||||
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle}
|
className={s.row}
|
||||||
className={s.thumb} />
|
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}>
|
<div className={s.info}>
|
||||||
<span className={s.mangaTitle}>{entry.mangaTitle}</span>
|
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
||||||
<span className={s.chapterName}>{entry.chapterName}
|
<span className={s.chapterName}>
|
||||||
{entry.pageNumber > 1 && (
|
{session.chapterCount > 1 ? (
|
||||||
<span className={s.pageBadge}>p.{entry.pageNumber}</span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={s.time}>{timeAgo(entry.readAt)}</span>
|
<span className={s.time}>{timeAgo(session.readAt)}</span>
|
||||||
<Play size={12} weight="fill" className={s.playIcon} />
|
<Play size={12} weight="fill" className={s.playIcon} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -107,22 +107,32 @@
|
|||||||
.search::placeholder { color: var(--text-faint); }
|
.search::placeholder { color: var(--text-faint); }
|
||||||
.search:focus { border-color: var(--border-strong); }
|
.search:focus { border-color: var(--border-strong); }
|
||||||
|
|
||||||
/* Grid */
|
/* Virtual row — flexbox instead of CSS grid so virtualizer controls height */
|
||||||
.grid {
|
.virtualRow {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
|
||||||
gap: var(--sp-4);
|
gap: var(--sp-4);
|
||||||
/* Contain stacking contexts for GPU layers */
|
padding: 0 var(--sp-6);
|
||||||
contain: layout style;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Individual card fills its flex slot */
|
||||||
.card {
|
.card {
|
||||||
|
flex: 1 1 130px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 200px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
/* Promote to own GPU layer on hover only */
|
}
|
||||||
|
|
||||||
|
.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 .cover { filter: brightness(1.06); }
|
||||||
@@ -177,38 +187,12 @@
|
|||||||
transition: color var(--t-base);
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show more */
|
/* Skeleton grid still uses CSS grid since it's fixed 12 items */
|
||||||
.showMore {
|
.grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: center;
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
padding: var(--sp-6) 0 var(--sp-4);
|
gap: var(--sp-4);
|
||||||
}
|
padding: var(--sp-4) var(--sp-6) 0;
|
||||||
|
|
||||||
.showMoreBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 7px 20px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.showMoreBtn:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.showMoreCount {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skeleton */
|
/* Skeleton */
|
||||||
@@ -225,6 +209,14 @@
|
|||||||
width: 80%;
|
width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ghost cards fill trailing grid space without taking interaction */
|
||||||
|
.ghostCard {
|
||||||
|
padding: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
.center {
|
.center {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+198
-103
@@ -1,21 +1,32 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, memo } from "react";
|
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash } from "@phosphor-icons/react";
|
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import s from "./Library.module.css";
|
import s from "./Library.module.css";
|
||||||
|
|
||||||
const INITIAL_PAGE_SIZE = 48;
|
const CARD_MIN_W = 130;
|
||||||
const PAGE_INCREMENT = 48;
|
const CARD_GAP = 16;
|
||||||
|
const ROW_HEIGHT = 260;
|
||||||
|
|
||||||
|
function FadeImg({ src, alt, className, objectFit }: { src: string; alt: string; className?: string; objectFit?: string }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src} alt={alt} className={className}
|
||||||
|
loading="lazy" decoding="async"
|
||||||
|
style={{ objectFit: (objectFit ?? "cover") as any, opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Memoized card to prevent re-renders when siblings change
|
|
||||||
const MangaCard = memo(function MangaCard({
|
const MangaCard = memo(function MangaCard({
|
||||||
manga,
|
manga, onClick, onContextMenu, cropCovers,
|
||||||
onClick,
|
|
||||||
onContextMenu,
|
|
||||||
cropCovers,
|
|
||||||
}: {
|
}: {
|
||||||
manga: Manga;
|
manga: Manga;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -25,13 +36,11 @@ const MangaCard = memo(function MangaCard({
|
|||||||
return (
|
return (
|
||||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||||
<div className={s.coverWrap}>
|
<div className={s.coverWrap}>
|
||||||
<img
|
<FadeImg
|
||||||
src={thumbUrl(manga.thumbnailUrl)}
|
src={thumbUrl(manga.thumbnailUrl)}
|
||||||
alt={manga.title}
|
alt={manga.title}
|
||||||
className={s.cover}
|
className={s.cover}
|
||||||
style={{ objectFit: cropCovers ? "cover" : "contain" }}
|
objectFit={cropCovers ? "cover" : "contain"}
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
/>
|
||||||
{!!manga.downloadCount && (
|
{!!manga.downloadCount && (
|
||||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
||||||
@@ -42,13 +51,21 @@ const MangaCard = memo(function MangaCard({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function fetchLibrary() {
|
||||||
|
return cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((lib) => lib.mangas.nodes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Library() {
|
export default function Library() {
|
||||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
|
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||||
@@ -56,65 +73,97 @@ export default function Library() {
|
|||||||
const settings = useStore((state) => state.settings);
|
const settings = useStore((state) => state.settings);
|
||||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||||
|
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||||
const folders = useStore((state) => state.settings.folders);
|
const folders = useStore((state) => state.settings.folders);
|
||||||
|
const addFolder = useStore((state) => state.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
||||||
|
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = useCallback((showLoading = false) => {
|
||||||
Promise.all([
|
if (showLoading) setLoading(true);
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
// Clear a previously failed cache entry so we actually retry the network call
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
if (!cache.has(CACHE_KEYS.LIBRARY)) {
|
||||||
])
|
// cache miss — fresh fetch, nothing to clear
|
||||||
.then(([all, lib]) => {
|
}
|
||||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
fetchLibrary()
|
||||||
setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m));
|
.then((nodes) => { setAllManga(nodes); setError(null); })
|
||||||
})
|
|
||||||
.catch((e) => setError(e.message))
|
.catch((e) => setError(e.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { setVisibleCount(INITIAL_PAGE_SIZE); }, [libraryFilter, search]);
|
// Initial load — delayed on first mount so the server has time to start.
|
||||||
|
// retryCount bumps force a re-run; manual retries clear the cache first.
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
loadData(false);
|
||||||
|
|
||||||
|
// Re-fetch when library cache is invalidated by other pages
|
||||||
|
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false));
|
||||||
|
return unsub;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [retryCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: 0 });
|
||||||
|
}, [libraryFilter, search]);
|
||||||
|
|
||||||
// Reset filter if the active folder tab gets hidden
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
||||||
if (activeFolder && !activeFolder.showTab) {
|
if (activeFolder && !activeFolder.showTab) setLibraryFilter("library");
|
||||||
setLibraryFilter("library");
|
|
||||||
}
|
|
||||||
}, [folders]);
|
}, [folders]);
|
||||||
|
|
||||||
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let items = allManga;
|
let items = allManga;
|
||||||
|
|
||||||
if (libraryFilter === "library") {
|
if (libraryFilter === "library") {
|
||||||
items = items.filter((m) => m.inLibrary);
|
items = items.filter((m) => m.inLibrary);
|
||||||
} else if (libraryFilter === "downloaded") {
|
} else if (libraryFilter === "downloaded") {
|
||||||
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
||||||
} else if (!isBuiltinFilter) {
|
} else if (!isBuiltinFilter) {
|
||||||
// folder filter
|
|
||||||
const folder = folders.find((f) => f.id === libraryFilter);
|
const folder = folders.find((f) => f.id === libraryFilter);
|
||||||
if (folder) {
|
if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id));
|
||||||
items = items.filter((m) => folder.mangaIds.includes(m.id));
|
|
||||||
}
|
}
|
||||||
}
|
if (libraryTagFilter.length > 0)
|
||||||
|
items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag)));
|
||||||
// tag filter only applies to library/all/folder views
|
|
||||||
if (libraryTagFilter.length > 0) {
|
|
||||||
items = items.filter((m) =>
|
|
||||||
libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
||||||
|
|
||||||
const visible = filtered.slice(0, visibleCount);
|
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
||||||
const hasMore = visibleCount < filtered.length;
|
const [containerWidth, setContainerWidth] = useState(800);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
setContainerWidth(entry.contentRect.width);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const result: Manga[][] = [];
|
||||||
|
for (let i = 0; i < filtered.length; i += cols)
|
||||||
|
result.push(filtered.slice(i, i + cols));
|
||||||
|
return result;
|
||||||
|
}, [filtered, cols]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => ROW_HEIGHT,
|
||||||
|
overscan: 3,
|
||||||
|
});
|
||||||
|
|
||||||
const handleCardClick = useCallback(
|
const handleCardClick = useCallback(
|
||||||
(m: Manga) => () => setActiveManga(m),
|
(m: Manga) => () => setActiveManga(m),
|
||||||
@@ -123,57 +172,96 @@ export default function Library() {
|
|||||||
|
|
||||||
async function removeFromLibrary(manga: Manga) {
|
async function removeFromLibrary(manga: Manga) {
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||||
|
// Optimistic update first, then invalidate cache
|
||||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
async function deleteAllDownloads(manga: Manga) {
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
||||||
const downloadedIds = data.chapters.nodes
|
const downloadedChapters = data.chapters.nodes.filter((c) => c.isDownloaded);
|
||||||
.filter((c) => c.isDownloaded)
|
const ids = downloadedChapters.map((c) => c.id);
|
||||||
.map((c) => c.id);
|
if (!ids.length) return;
|
||||||
if (!downloadedIds.length) return;
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: downloadedIds });
|
await Promise.allSettled(ids.map((id) => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
||||||
setAllManga((prev) =>
|
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
||||||
prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m)
|
} catch (e) { console.error(e); }
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const menuW = 200;
|
const x = Math.min(e.clientX, window.innerWidth - 208);
|
||||||
const menuH = 160;
|
const y = Math.min(e.clientY, window.innerHeight - 168);
|
||||||
const x = Math.min(e.clientX, window.innerWidth - menuW - 8);
|
|
||||||
const y = Math.min(e.clientY, window.innerHeight - menuH - 8);
|
|
||||||
setCtx({ x, y, manga: m });
|
setCtx({ x, y, manga: m });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
|
const mangaFolderEntries: ContextMenuEntry[] = folders.map((f) => {
|
||||||
|
const inFolder = f.mangaIds.includes(m.id);
|
||||||
|
return {
|
||||||
|
label: inFolder ? `✓ ${f.name}` : f.name,
|
||||||
|
icon: <Folder size={13} weight={inFolder ? "fill" : "light"} />,
|
||||||
|
onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: "Open",
|
label: "Open",
|
||||||
|
icon: <BookOpen size={13} weight="light" />,
|
||||||
onClick: () => setActiveManga(m),
|
onClick: () => setActiveManga(m),
|
||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: m.inLibrary ? "Remove from library" : "Add to library",
|
label: m.inLibrary ? "Remove from library" : "Add to library",
|
||||||
|
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||||
danger: m.inLibrary,
|
danger: m.inLibrary,
|
||||||
onClick: () => m.inLibrary
|
onClick: () => m.inLibrary
|
||||||
? removeFromLibrary(m)
|
? removeFromLibrary(m)
|
||||||
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
.then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
|
.then(() => {
|
||||||
|
setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
})
|
||||||
.catch(console.error),
|
.catch(console.error),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Delete all downloads",
|
label: "Delete all downloads",
|
||||||
|
icon: <Trash size={13} weight="light" />,
|
||||||
danger: true,
|
danger: true,
|
||||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
disabled: !(m.downloadCount && m.downloadCount > 0),
|
||||||
icon: <Trash size={13} weight="light" />,
|
|
||||||
onClick: () => deleteAllDownloads(m),
|
onClick: () => deleteAllDownloads(m),
|
||||||
},
|
},
|
||||||
|
...(folders.length > 0 ? [
|
||||||
|
{ separator: true } as ContextMenuEntry,
|
||||||
|
...mangaFolderEntries,
|
||||||
|
] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "New folder",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) {
|
||||||
|
const id = addFolder(name.trim());
|
||||||
|
assignMangaToFolder(id, m.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmptyCtxItems(): ContextMenuEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "New folder",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) addFolder(name.trim());
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,26 +277,37 @@ export default function Library() {
|
|||||||
library: allManga.filter((m) => m.inLibrary).length,
|
library: allManga.filter((m) => m.inLibrary).length,
|
||||||
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
||||||
};
|
};
|
||||||
folders.forEach((f) => {
|
folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; });
|
||||||
result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length;
|
|
||||||
});
|
|
||||||
return result;
|
return result;
|
||||||
}, [allManga, folders]);
|
}, [allManga, folders]);
|
||||||
|
|
||||||
if (error) return (
|
if (error) return (
|
||||||
<div className={s.center}>
|
<div className={s.center}>
|
||||||
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
||||||
<p className={s.errorDetail}>{error}</p>
|
<p className={s.errorDetail}>Make sure the server is running, then retry.</p>
|
||||||
|
<button
|
||||||
|
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||||
|
onClick={() => setRetryCount((c) => c + 1)}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root}>
|
<div
|
||||||
|
className={s.root}
|
||||||
|
ref={scrollRef}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setEmptyCtx({ x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<div className={s.headerLeft}>
|
<div className={s.headerLeft}>
|
||||||
<h1 className={s.heading}>Library</h1>
|
<h1 className={s.heading}>Library</h1>
|
||||||
<div className={s.tabs}>
|
<div className={s.tabs}>
|
||||||
{/* Built-in tabs */}
|
|
||||||
{(["library", "downloaded", "all"] as const).map((f) => (
|
{(["library", "downloaded", "all"] as const).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
@@ -219,13 +318,10 @@ export default function Library() {
|
|||||||
<><Books size={11} weight="bold" /> Saved</>
|
<><Books size={11} weight="bold" /> Saved</>
|
||||||
) : f === "downloaded" ? (
|
) : f === "downloaded" ? (
|
||||||
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
||||||
) : (
|
) : <>All</>}
|
||||||
<>All</>
|
|
||||||
)}
|
|
||||||
<span className={s.tabCount}>{counts[f]}</span>
|
<span className={s.tabCount}>{counts[f]}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{/* Folder tabs — only shown if the folder has showTab enabled */}
|
|
||||||
{folders.filter((f) => f.showTab).map((folder) => (
|
{folders.filter((f) => f.showTab).map((folder) => (
|
||||||
<button
|
<button
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
@@ -250,13 +346,11 @@ export default function Library() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag filter panel */}
|
|
||||||
{allTags.length > 0 && (
|
{allTags.length > 0 && (
|
||||||
<div className={s.tagPanel}>
|
<div className={s.tagPanel}>
|
||||||
{libraryTagFilter.length > 0 && (
|
{libraryTagFilter.length > 0 && (
|
||||||
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
||||||
<X size={11} weight="bold" />
|
<X size={11} weight="bold" /> Clear
|
||||||
Clear
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{allTags.map((tag) => {
|
{allTags.map((tag) => {
|
||||||
@@ -264,13 +358,7 @@ export default function Library() {
|
|||||||
return (
|
return (
|
||||||
<button key={tag}
|
<button key={tag}
|
||||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
||||||
onClick={() =>
|
onClick={() => setGenreFilter(tag)}>
|
||||||
setLibraryTagFilter(
|
|
||||||
active
|
|
||||||
? libraryTagFilter.filter((t) => t !== tag)
|
|
||||||
: [...libraryTagFilter, tag]
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
{tag}
|
{tag}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -290,7 +378,7 @@ export default function Library() {
|
|||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className={s.center}>
|
<div className={s.center}>
|
||||||
{libraryFilter === "library"
|
{libraryFilter === "library"
|
||||||
? "No manga saved to library. Browse sources to add some."
|
? "No manga saved to library, browse sources to add some."
|
||||||
: libraryFilter === "downloaded"
|
: libraryFilter === "downloaded"
|
||||||
? "No downloaded manga."
|
? "No downloaded manga."
|
||||||
: !isBuiltinFilter
|
: !isBuiltinFilter
|
||||||
@@ -298,9 +386,22 @@ export default function Library() {
|
|||||||
: "No manga found."}
|
: "No manga found."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
<div className={s.grid}>
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
{visible.map((m) => (
|
const rowManga = rows[virtualRow.index];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: virtualRow.start,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: virtualRow.size,
|
||||||
|
}}
|
||||||
|
className={s.virtualRow}
|
||||||
|
>
|
||||||
|
{rowManga.map((m) => (
|
||||||
<MangaCard
|
<MangaCard
|
||||||
key={m.id}
|
key={m.id}
|
||||||
manga={m}
|
manga={m}
|
||||||
@@ -309,27 +410,21 @@ export default function Library() {
|
|||||||
cropCovers={settings.libraryCropCovers}
|
cropCovers={settings.libraryCropCovers}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{virtualRow.index === rows.length - 1 &&
|
||||||
|
Array.from({ length: cols - rowManga.length }).map((_, i) => (
|
||||||
|
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{hasMore && (
|
);
|
||||||
<div className={s.showMore}>
|
})}
|
||||||
<button
|
|
||||||
className={s.showMoreBtn}
|
|
||||||
onClick={() => setVisibleCount((c) => c + PAGE_INCREMENT)}
|
|
||||||
>
|
|
||||||
Show more
|
|
||||||
<span className={s.showMoreCount}>{filtered.length - visibleCount} remaining</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{ctx && (
|
{ctx && (
|
||||||
<ContextMenu
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||||
x={ctx.x}
|
)}
|
||||||
y={ctx.y}
|
{emptyCtx && (
|
||||||
items={buildCtxItems(ctx.manga)}
|
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtxItems()} onClose={() => setEmptyCtx(null)} />
|
||||||
onClose={() => setCtx(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -476,3 +476,153 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
||||||
}
|
}
|
||||||
|
/* ── Source context pill (step 2 header) ── */
|
||||||
|
.searchContext {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchContextIcon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchContextName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchContextChange {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.searchContextChange:hover { opacity: 0.75; }
|
||||||
|
|
||||||
|
/* ── Result row: updated layout with similarity ── */
|
||||||
|
.resultInfo {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bestMatchBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simBar {
|
||||||
|
width: 48px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simFill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Confirm step additions ── */
|
||||||
|
.confirmDivider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmTag {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmTagNew {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statGood { color: var(--color-success) !important; }
|
||||||
|
.statWarn { color: #d97706 !important; }
|
||||||
|
.statBad { color: var(--color-error) !important; }
|
||||||
|
|
||||||
|
.chapterDiff {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: #d97706;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
margin-left: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: rgba(217, 119, 6, 0.08);
|
||||||
|
border: 1px solid rgba(217, 119, 6, 0.25);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: #d97706;
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning } from "@phosphor-icons/react";
|
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||||
@@ -18,6 +18,19 @@ interface Match {
|
|||||||
manga: Manga;
|
manga: Manga;
|
||||||
chapters: Chapter[];
|
chapters: Chapter[];
|
||||||
readCount: number;
|
readCount: number;
|
||||||
|
similarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple title similarity: normalise → word overlap / Jaccard
|
||||||
|
function titleSimilarity(a: string, b: string): number {
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||||
|
const wordsA = new Set(norm(a));
|
||||||
|
const wordsB = new Set(norm(b));
|
||||||
|
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||||
|
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
||||||
|
const union = new Set([...wordsA, ...wordsB]).size;
|
||||||
|
return intersection / union;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
|
export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
|
||||||
@@ -26,10 +39,10 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
const [loadingSources, setLoadingSources] = useState(true);
|
const [loadingSources, setLoadingSources] = useState(true);
|
||||||
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
||||||
const [query, setQuery] = useState(manga.title);
|
const [query, setQuery] = useState(manga.title);
|
||||||
const [results, setResults] = useState<Manga[]>([]);
|
const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||||
const [loadingMatch, setLoadingMatch] = useState(false);
|
const [loadingMatchId, setLoadingMatchId] = useState<number | null>(null);
|
||||||
const [migrating, setMigrating] = useState(false);
|
const [migrating, setMigrating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -40,25 +53,38 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
.finally(() => setLoadingSources(false));
|
.finally(() => setLoadingSources(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function searchSource() {
|
const searchSource = useCallback(async (src: Source, q: string) => {
|
||||||
if (!selectedSource || !query.trim()) return;
|
if (!src || !q.trim()) return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
source: selectedSource.id, type: "SEARCH", page: 1, query: query.trim(),
|
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
|
||||||
});
|
});
|
||||||
setResults(d.fetchSourceManga.mangas);
|
const scored = d.fetchSourceManga.mangas.map((m) => ({
|
||||||
|
manga: m,
|
||||||
|
similarity: titleSimilarity(manga.title, m.title),
|
||||||
|
}));
|
||||||
|
// Sort by similarity desc so best matches float to top
|
||||||
|
scored.sort((a, b) => b.similarity - a.similarity);
|
||||||
|
setResults(scored);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
|
}, [manga.title]);
|
||||||
|
|
||||||
|
function pickSource(src: Source) {
|
||||||
|
setSelectedSource(src);
|
||||||
|
setStep("search");
|
||||||
|
// Auto-search immediately with original title
|
||||||
|
searchSource(src, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectMatch(m: Manga) {
|
async function selectMatch(m: Manga, similarity: number) {
|
||||||
setLoadingMatch(true);
|
setLoadingMatchId(m.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
||||||
@@ -67,12 +93,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
||||||
return old?.isRead;
|
return old?.isRead;
|
||||||
}).length;
|
}).length;
|
||||||
setSelectedMatch({ manga: m, chapters, readCount });
|
setSelectedMatch({ manga: m, chapters, readCount, similarity });
|
||||||
setStep("confirm");
|
setStep("confirm");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMatch(false);
|
setLoadingMatchId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +108,6 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
||||||
|
|
||||||
// Build read/bookmark/progress maps from old chapters keyed by chapterNumber
|
|
||||||
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
||||||
|
|
||||||
const toMarkRead: number[] = [];
|
const toMarkRead: number[] = [];
|
||||||
@@ -96,25 +120,17 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
if (!old) continue;
|
if (!old) continue;
|
||||||
if (old.isRead) toMarkRead.push(nc.id);
|
if (old.isRead) toMarkRead.push(nc.id);
|
||||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead) {
|
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
||||||
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate read state
|
if (toMarkRead.length)
|
||||||
if (toMarkRead.length) {
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||||
}
|
if (toMarkBookmarked.length)
|
||||||
// Migrate bookmarks
|
|
||||||
if (toMarkBookmarked.length) {
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||||
}
|
for (const { id, lastPageRead } of progressUpdates)
|
||||||
// Migrate in-progress pages one by one (different lastPageRead per chapter)
|
|
||||||
for (const { id, lastPageRead } of progressUpdates) {
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
||||||
}
|
|
||||||
|
|
||||||
// Add new to library, remove old
|
|
||||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
||||||
|
|
||||||
@@ -128,30 +144,45 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
const readCount = currentChapters.filter((c) => c.isRead).length;
|
const readCount = currentChapters.filter((c) => c.isRead).length;
|
||||||
const totalCount = currentChapters.length;
|
const totalCount = currentChapters.length;
|
||||||
|
|
||||||
|
const chapterDiff = selectedMatch
|
||||||
|
? selectedMatch.chapters.length - totalCount
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const STEPS: Step[] = ["source", "search", "confirm"];
|
||||||
|
const stepIdx = STEPS.indexOf(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
<div className={s.modal}>
|
<div className={s.modal}>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
<div className={s.modalHeader}>
|
<div className={s.modalHeader}>
|
||||||
<div className={s.modalTitle}>
|
<div className={s.modalTitle}>
|
||||||
<span className={s.modalTitleLabel}>Migrate source</span>
|
<span className={s.modalTitleLabel}>Migrate source</span>
|
||||||
<span className={s.modalTitleManga}>{manga.title}</span>
|
<span className={s.modalTitleManga}>{manga.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className={s.closeBtn} onClick={onClose}>
|
<button className={s.closeBtn} onClick={onClose}><X size={14} weight="light" /></button>
|
||||||
<X size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Step indicators ── */}
|
{/* ── Step indicators ── */}
|
||||||
<div className={s.steps}>
|
<div className={s.steps}>
|
||||||
{(["source", "search", "confirm"] as Step[]).map((st, i) => (
|
{STEPS.map((st, i) => (
|
||||||
<div key={st} className={[s.step, step === st ? s.stepActive : "", i < ["source","search","confirm"].indexOf(step) ? s.stepDone : ""].join(" ").trim()}>
|
<div key={st}
|
||||||
<span className={s.stepDot}>{i < ["source","search","confirm"].indexOf(step) ? <Check size={9} weight="bold" /> : i + 1}</span>
|
className={[s.step, step === st ? s.stepActive : "", i < stepIdx ? s.stepDone : ""].join(" ").trim()}>
|
||||||
<span className={s.stepLabel}>{st.charAt(0).toUpperCase() + st.slice(1)}</span>
|
<span className={s.stepDot}>
|
||||||
|
{i < stepIdx ? <Check size={9} weight="bold" /> : i + 1}
|
||||||
|
</span>
|
||||||
|
<span className={s.stepLabel}>
|
||||||
|
{st === "source" ? "Pick source"
|
||||||
|
: st === "search" ? (selectedSource ? selectedSource.displayName : "Search")
|
||||||
|
: "Confirm"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.body}>
|
<div className={s.body}>
|
||||||
|
|
||||||
{/* ── Step 1: Pick source ── */}
|
{/* ── Step 1: Pick source ── */}
|
||||||
{step === "source" && (
|
{step === "source" && (
|
||||||
<div className={s.sourceList}>
|
<div className={s.sourceList}>
|
||||||
@@ -163,11 +194,9 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
||||||
) : (
|
) : (
|
||||||
sources.map((src) => (
|
sources.map((src) => (
|
||||||
<button
|
<button key={src.id}
|
||||||
key={src.id}
|
|
||||||
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
||||||
onClick={() => { setSelectedSource(src); setStep("search"); searchSource(); }}
|
onClick={() => pickSource(src)}>
|
||||||
>
|
|
||||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
<div className={s.sourceInfo}>
|
<div className={s.sourceInfo}>
|
||||||
@@ -184,22 +213,34 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
{/* ── Step 2: Search & pick match ── */}
|
{/* ── Step 2: Search & pick match ── */}
|
||||||
{step === "search" && (
|
{step === "search" && (
|
||||||
<div className={s.searchStep}>
|
<div className={s.searchStep}>
|
||||||
|
|
||||||
|
{/* Source context pill */}
|
||||||
|
{selectedSource && (
|
||||||
|
<div className={s.searchContext}>
|
||||||
|
<img src={thumbUrl(selectedSource.iconUrl)} alt="" className={s.searchContextIcon}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span className={s.searchContextName}>{selectedSource.displayName}</span>
|
||||||
|
<button className={s.searchContextChange} onClick={() => { setStep("source"); setResults([]); }}>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={s.searchRow}>
|
<div className={s.searchRow}>
|
||||||
<div className={s.searchBar}>
|
<div className={s.searchBar}>
|
||||||
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
||||||
<input
|
<input className={s.searchInput} value={query}
|
||||||
className={s.searchInput}
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && searchSource()}
|
onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||||
autoFocus
|
placeholder="Search title…"
|
||||||
/>
|
autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<button className={s.searchBtn} onClick={searchSource} disabled={searching}>
|
<button className={s.searchBtn}
|
||||||
{searching ? <CircleNotch size={13} weight="light" className="anim-spin" /> : "Search"}
|
onClick={() => selectedSource && searchSource(selectedSource, query)}
|
||||||
</button>
|
disabled={searching || !selectedSource}>
|
||||||
<button className={s.backBtn} onClick={() => { setStep("source"); setResults([]); }}>
|
{searching
|
||||||
Back
|
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||||
|
: <><MagnifyingGlass size={12} weight="bold" /> Search</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,25 +252,40 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
<div className={["skeleton", s.skCover].join(" ")} />
|
<div className={["skeleton", s.skCover].join(" ")} />
|
||||||
<div className={s.skMeta}>
|
<div className={s.skMeta}>
|
||||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
|
<div className={["skeleton", s.skTitle].join(" ")} style={{ width: "40%" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!searching && results.map((m) => (
|
{!searching && results.map(({ manga: m, similarity }, idx) => (
|
||||||
<button
|
<button key={m.id} className={s.resultRow}
|
||||||
key={m.id}
|
onClick={() => selectMatch(m, similarity)}
|
||||||
className={s.resultRow}
|
disabled={loadingMatchId !== null}>
|
||||||
onClick={() => selectMatch(m)}
|
|
||||||
disabled={loadingMatch}
|
|
||||||
>
|
|
||||||
<div className={s.resultCoverWrap}>
|
<div className={s.resultCoverWrap}>
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={s.resultInfo}>
|
||||||
<span className={s.resultTitle}>{m.title}</span>
|
<span className={s.resultTitle}>{m.title}</span>
|
||||||
{loadingMatch && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
|
<div className={s.resultMeta}>
|
||||||
|
{idx === 0 && similarity > 0.5 && (
|
||||||
|
<span className={s.bestMatchBadge}>
|
||||||
|
<Sparkle size={9} weight="fill" /> Best match
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={s.simBar}>
|
||||||
|
<span className={s.simFill} style={{ width: `${Math.round(similarity * 100)}%` }} />
|
||||||
|
</span>
|
||||||
|
<span className={s.simLabel}>{Math.round(similarity * 100)}% match</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loadingMatchId === m.id
|
||||||
|
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
|
||||||
|
: <ArrowRight size={13} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0, opacity: 0.5 }} />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{!searching && results.length === 0 && query && (
|
{!searching && results.length === 0 && !error && (
|
||||||
<div className={s.centered}><span className={s.hint}>No results.</span></div>
|
<div className={s.centered}>
|
||||||
|
<span className={s.hint}>{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,9 +301,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
</div>
|
</div>
|
||||||
<p className={s.confirmTitle}>{manga.title}</p>
|
<p className={s.confirmTitle}>{manga.title}</p>
|
||||||
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
||||||
|
<span className={s.confirmTag}>Current</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ArrowRight size={20} weight="light" className={s.confirmArrow} />
|
<div className={s.confirmDivider}>
|
||||||
|
<ArrowRight size={16} weight="light" className={s.confirmArrow} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={s.confirmManga}>
|
<div className={s.confirmManga}>
|
||||||
<div className={s.confirmCoverWrap}>
|
<div className={s.confirmCoverWrap}>
|
||||||
@@ -255,24 +314,39 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
</div>
|
</div>
|
||||||
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
||||||
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
||||||
|
<span className={[s.confirmTag, s.confirmTagNew].join(" ")}>New</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.confirmStats}>
|
<div className={s.confirmStats}>
|
||||||
|
<div className={s.statRow}>
|
||||||
|
<span className={s.statLabel}>Title match</span>
|
||||||
|
<span className={[s.statVal, selectedMatch.similarity > 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}>
|
||||||
|
{Math.round(selectedMatch.similarity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className={s.statRow}>
|
<div className={s.statRow}>
|
||||||
<span className={s.statLabel}>Chapters on new source</span>
|
<span className={s.statLabel}>Chapters on new source</span>
|
||||||
<span className={s.statVal}>{selectedMatch.chapters.length}</span>
|
<span className={[s.statVal, chapterDiff < -5 ? s.statWarn : ""].join(" ").trim()}>
|
||||||
|
{selectedMatch.chapters.length}
|
||||||
|
{chapterDiff !== 0 && (
|
||||||
|
<span className={s.chapterDiff}>{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={s.statRow}>
|
<div className={s.statRow}>
|
||||||
<span className={s.statLabel}>Read progress to migrate</span>
|
<span className={s.statLabel}>Read progress to carry over</span>
|
||||||
<span className={s.statVal}>{readCount} / {totalCount} chapters</span>
|
<span className={s.statVal}>{selectedMatch.readCount} / {readCount} chapters</span>
|
||||||
</div>
|
|
||||||
<div className={s.statRow}>
|
|
||||||
<span className={s.statLabel}>Matched chapters</span>
|
|
||||||
<span className={s.statVal}>{selectedMatch.readCount} will carry over</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{chapterDiff < -5 && (
|
||||||
|
<div className={s.warnBox}>
|
||||||
|
<Warning size={13} weight="light" />
|
||||||
|
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className={s.confirmNote}>
|
<p className={s.confirmNote}>
|
||||||
The current entry will be removed from your library. Downloads are not transferred.
|
The current entry will be removed from your library. Downloads are not transferred.
|
||||||
</p>
|
</p>
|
||||||
@@ -286,7 +360,7 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
||||||
{migrating
|
{migrating
|
||||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
||||||
: "Migrate"}
|
: <><Check size={13} weight="bold" /> Migrate</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+137
-49
@@ -77,53 +77,86 @@ function DownloadModal({
|
|||||||
remaining,
|
remaining,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
chapter: { id: number; name: string };
|
chapter: { id: number; name: string; isDownloaded?: boolean };
|
||||||
remaining: { id: number }[];
|
remaining: { id: number; isDownloaded?: boolean }[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const addToast = useStore((s) => s.addToast);
|
||||||
const [nextN, setNextN] = useState(5);
|
const [nextN, setNextN] = useState(5);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const run = async (fn: () => Promise<unknown>) => {
|
// Only offer chapters that aren't already downloaded
|
||||||
|
const queueable = remaining.filter((c) => !c.isDownloaded);
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<unknown>, toastBody: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
await fn().catch(console.error);
|
try {
|
||||||
|
await fn();
|
||||||
|
addToast({ kind: "download", title: "Download queued", body: toastBody });
|
||||||
|
} catch (e) {
|
||||||
|
addToast({ kind: "error", title: "Queue failed", body: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const thisAlreadyDl = !!chapter.isDownloaded;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.dlBackdrop} onClick={onClose}>
|
<div className={s.dlBackdrop} onClick={onClose}>
|
||||||
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
|
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
|
||||||
<p className={s.dlTitle}>Download</p>
|
<p className={s.dlTitle}>Download</p>
|
||||||
<button className={s.dlOption} disabled={busy}
|
<button
|
||||||
onClick={() => run(() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }))}>
|
className={s.dlOption}
|
||||||
|
disabled={busy || thisAlreadyDl}
|
||||||
|
onClick={() => run(
|
||||||
|
() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }),
|
||||||
|
thisAlreadyDl ? "" : chapter.name,
|
||||||
|
)}
|
||||||
|
>
|
||||||
This chapter
|
This chapter
|
||||||
<span className={s.dlSub}>{chapter.name}</span>
|
<span className={s.dlSub}>
|
||||||
|
{thisAlreadyDl ? "Already downloaded" : chapter.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={s.dlRow}>
|
<div className={s.dlRow}>
|
||||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
<button
|
||||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
className={s.dlOption}
|
||||||
chapterIds: remaining.slice(0, nextN).map((c) => c.id),
|
disabled={busy || queueable.length === 0}
|
||||||
}))}>
|
onClick={() => run(
|
||||||
|
() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
||||||
|
chapterIds: queueable.slice(0, nextN).map((c) => c.id),
|
||||||
|
}),
|
||||||
|
`${Math.min(nextN, queueable.length)} chapters queued`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
Next chapters
|
Next chapters
|
||||||
<span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span>
|
<span className={s.dlSub}>{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
|
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
|
||||||
<button className={s.dlStepBtn}
|
<button
|
||||||
|
className={s.dlStepBtn}
|
||||||
onClick={() => setNextN((n) => Math.max(1, n - 1))}
|
onClick={() => setNextN((n) => Math.max(1, n - 1))}
|
||||||
disabled={nextN <= 1}>−</button>
|
disabled={nextN <= 1}
|
||||||
|
>−</button>
|
||||||
<span className={s.dlStepVal}>{nextN}</span>
|
<span className={s.dlStepVal}>{nextN}</span>
|
||||||
<button className={s.dlStepBtn}
|
<button
|
||||||
onClick={() => setNextN((n) => Math.min(remaining.length || 1, n + 1))}
|
className={s.dlStepBtn}
|
||||||
disabled={nextN >= remaining.length}>+</button>
|
onClick={() => setNextN((n) => Math.min(queueable.length || 1, n + 1))}
|
||||||
|
disabled={nextN >= queueable.length}
|
||||||
|
>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
<button
|
||||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
className={s.dlOption}
|
||||||
chapterIds: remaining.map((c) => c.id),
|
disabled={busy || queueable.length === 0}
|
||||||
}))}>
|
onClick={() => run(
|
||||||
|
() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map((c) => c.id) }),
|
||||||
|
`${queueable.length} chapter${queueable.length !== 1 ? "s" : ""} queued`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
All remaining
|
All remaining
|
||||||
<span className={s.dlSub}>{remaining.length} chapters</span>
|
<span className={s.dlSub}>{queueable.length} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +239,7 @@ export default function Reader() {
|
|||||||
const [dlOpen, setDlOpen] = useState(false);
|
const [dlOpen, setDlOpen] = useState(false);
|
||||||
const [zoomOpen, setZoomOpen] = useState(false);
|
const [zoomOpen, setZoomOpen] = useState(false);
|
||||||
const [uiVisible, setUiVisible] = useState(true);
|
const [uiVisible, setUiVisible] = useState(true);
|
||||||
const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
|
const markedReadRef = useRef<Set<number>>(new Set());
|
||||||
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
||||||
// True only after the first page of the new chapter has been decoded,
|
// True only after the first page of the new chapter has been decoded,
|
||||||
// preventing any flash of the previous chapter's image.
|
// preventing any flash of the previous chapter's image.
|
||||||
@@ -223,9 +256,14 @@ export default function Reader() {
|
|||||||
* currently reading (for topbar display) without triggering a full reload.
|
* currently reading (for topbar display) without triggering a full reload.
|
||||||
*/
|
*/
|
||||||
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
||||||
|
// Ref mirror so the scroll handler always reads the latest value without
|
||||||
|
// closing over a stale state snapshot from a previous effect render.
|
||||||
|
const visibleChapterIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Keep the ref mirror in sync so the scroll handler always sees current strip state
|
// Keep the ref mirror in sync so the scroll handler always sees current strip state
|
||||||
useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]);
|
useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]);
|
||||||
|
// Keep visibleChapterId ref in sync
|
||||||
|
useEffect(() => { visibleChapterIdRef.current = visibleChapterId; }, [visibleChapterId]);
|
||||||
|
|
||||||
// Restore scroll position synchronously after a head-trim, before the browser paints
|
// Restore scroll position synchronously after a head-trim, before the browser paints
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -319,6 +357,7 @@ export default function Reader() {
|
|||||||
setLoading(true); setError(null); setPageGroups([]); setPageReady(false);
|
setLoading(true); setError(null); setPageGroups([]); setPageReady(false);
|
||||||
// Reset strip state for new chapter navigation (non-scroll transitions)
|
// Reset strip state for new chapter navigation (non-scroll transitions)
|
||||||
appendedRef.current = new Set();
|
appendedRef.current = new Set();
|
||||||
|
markedReadRef.current = new Set();
|
||||||
|
|
||||||
const targetId = activeChapter.id;
|
const targetId = activeChapter.id;
|
||||||
loadingChapterRef.current = targetId;
|
loadingChapterRef.current = targetId;
|
||||||
@@ -328,8 +367,11 @@ export default function Reader() {
|
|||||||
// Discard result if the user has already navigated to a different chapter
|
// Discard result if the user has already navigated to a different chapter
|
||||||
if (loadingChapterRef.current !== targetId) return;
|
if (loadingChapterRef.current !== targetId) return;
|
||||||
|
|
||||||
// Decode the first page before committing so no previous chapter flashes
|
// Decode the first page before committing so no previous chapter flashes.
|
||||||
|
// In longstrip mode skip the blocking decode — images stream in naturally.
|
||||||
|
if (style !== "longstrip") {
|
||||||
await decodeImage(urls[0]);
|
await decodeImage(urls[0]);
|
||||||
|
}
|
||||||
|
|
||||||
if (loadingChapterRef.current !== targetId) return;
|
if (loadingChapterRef.current !== targetId) return;
|
||||||
|
|
||||||
@@ -347,10 +389,14 @@ export default function Reader() {
|
|||||||
setStripChapters([]);
|
setStripChapters([]);
|
||||||
setVisibleChapterId(null);
|
setVisibleChapterId(null);
|
||||||
}
|
}
|
||||||
|
// Only clear loading after state is fully committed — no flash frames
|
||||||
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
.catch((e) => {
|
||||||
.finally(() => {
|
if (loadingChapterRef.current === targetId) {
|
||||||
if (loadingChapterRef.current === targetId) setLoading(false);
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [activeChapter?.id]);
|
}, [activeChapter?.id]);
|
||||||
|
|
||||||
@@ -483,11 +529,13 @@ export default function Reader() {
|
|||||||
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
|
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (settings.autoMarkRead && pageNumber === lastPage && !markedRead.has(activeChapter.id)) {
|
if (settings.autoMarkRead && pageNumber === lastPage) {
|
||||||
setMarkedRead((p) => new Set(p).add(activeChapter.id));
|
if (!markedReadRef.current.has(activeChapter.id)) {
|
||||||
|
markedReadRef.current.add(activeChapter.id);
|
||||||
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
|
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
|
||||||
}
|
}
|
||||||
}, [pageNumber, lastPage, activeChapter?.id]);
|
}
|
||||||
|
}, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead]);
|
||||||
|
|
||||||
// ── Navigation ──────────────────────────────────────────────────────────────
|
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||||
const advanceGroup = useCallback((forward: boolean) => {
|
const advanceGroup = useCallback((forward: boolean) => {
|
||||||
@@ -504,6 +552,7 @@ export default function Reader() {
|
|||||||
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
|
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
|
||||||
|
|
||||||
const goForward = useCallback(() => {
|
const goForward = useCallback(() => {
|
||||||
|
if (loading || !pageUrls.length) return;
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||||
if (pageNumber < lastPage) {
|
if (pageNumber < lastPage) {
|
||||||
const nextUrl = pageUrls[pageNumber]; // pageNumber is 1-based, so index is pageNumber
|
const nextUrl = pageUrls[pageNumber]; // pageNumber is 1-based, so index is pageNumber
|
||||||
@@ -518,9 +567,10 @@ export default function Reader() {
|
|||||||
} else {
|
} else {
|
||||||
closeReader();
|
closeReader();
|
||||||
}
|
}
|
||||||
}, [pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
}, [loading, pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
|
if (loading || !pageUrls.length) return;
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||||
if (pageNumber > 1) {
|
if (pageNumber > 1) {
|
||||||
const prevUrl = pageUrls[pageNumber - 2]; // 0-based index of previous page
|
const prevUrl = pageUrls[pageNumber - 2]; // 0-based index of previous page
|
||||||
@@ -532,7 +582,7 @@ export default function Reader() {
|
|||||||
} else if (adjacent.prev) {
|
} else if (adjacent.prev) {
|
||||||
openReader(adjacent.prev, activeChapterList);
|
openReader(adjacent.prev, activeChapterList);
|
||||||
}
|
}
|
||||||
}, [pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
}, [loading, pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||||
|
|
||||||
const goNext = rtl ? goBack : goForward;
|
const goNext = rtl ? goBack : goForward;
|
||||||
const goPrev = rtl ? goForward : goBack;
|
const goPrev = rtl ? goForward : goBack;
|
||||||
@@ -575,13 +625,30 @@ export default function Reader() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl += / Ctrl + / Ctrl - / Ctrl 0 → zoom
|
||||||
|
if (e.ctrlKey && (e.key === "=" || e.key === "+")) {
|
||||||
|
e.preventDefault();
|
||||||
|
updateSettings({ maxPageWidth: Math.min(2400, maxW + 100) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.key === "-") {
|
||||||
|
e.preventDefault();
|
||||||
|
updateSettings({ maxPageWidth: Math.max(200, maxW - 100) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.key === "0") {
|
||||||
|
e.preventDefault();
|
||||||
|
updateSettings({ maxPageWidth: 900 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
||||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
|
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
|
||||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
|
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
|
||||||
else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
|
else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (!loading && adjacent.next) openReader(adjacent.next, activeChapterList); }
|
||||||
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
|
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (!loading && adjacent.prev) openReader(adjacent.prev, activeChapterList); }
|
||||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
||||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
|
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
|
||||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||||
@@ -589,7 +656,7 @@ export default function Reader() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen]);
|
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen, maxW, loading]);
|
||||||
|
|
||||||
// ── Longstrip scroll tracker ─────────────────────────────────────────────────
|
// ── Longstrip scroll tracker ─────────────────────────────────────────────────
|
||||||
// Tracks current page number. In autoNext mode, appends the next chapter's
|
// Tracks current page number. In autoNext mode, appends the next chapter's
|
||||||
@@ -619,34 +686,54 @@ export default function Reader() {
|
|||||||
|
|
||||||
// ── Infinite append ──────────────────────────────────────────────────
|
// ── Infinite append ──────────────────────────────────────────────────
|
||||||
if (!autoNext) {
|
if (!autoNext) {
|
||||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
// Only navigate when the strip genuinely overflows the viewport.
|
||||||
|
// If pages are short/zoomed-out, scrollHeight === clientHeight and
|
||||||
|
// atBottom would always be true, causing unwanted chapter switches.
|
||||||
|
const isScrollable = el.scrollHeight > el.clientHeight + 4;
|
||||||
|
const atBottom = isScrollable && el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
||||||
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
|
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const strip = stripChaptersRef.current;
|
const strip = stripChaptersRef.current;
|
||||||
|
|
||||||
// Silently update visibleChapterId as we scroll into each chunk
|
// Silently update visibleChapterId as we scroll into each chunk.
|
||||||
|
// Use the ref so we always compare against the current value, not a
|
||||||
|
// stale closure snapshot from when the effect was last set up.
|
||||||
for (const chunk of strip) {
|
for (const chunk of strip) {
|
||||||
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
|
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
|
||||||
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
|
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
|
||||||
if (chunk.chapterId !== visibleChapterId) {
|
if (chunk.chapterId !== visibleChapterIdRef.current) {
|
||||||
setVisibleChapterId(chunk.chapterId);
|
// Mark the chapter we just *left* as read before updating the ref.
|
||||||
if (settings.autoMarkRead) {
|
if (settings.autoMarkRead) {
|
||||||
const prevChunk = strip[strip.indexOf(chunk) - 1];
|
const chunkIdx = strip.indexOf(chunk);
|
||||||
if (prevChunk) {
|
const prevChunk = chunkIdx > 0 ? strip[chunkIdx - 1] : null;
|
||||||
setMarkedRead((r) => {
|
if (prevChunk && !markedReadRef.current.has(prevChunk.chapterId)) {
|
||||||
if (r.has(prevChunk.chapterId)) return r;
|
markedReadRef.current.add(prevChunk.chapterId);
|
||||||
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
||||||
return new Set(r).add(prevChunk.chapterId);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
visibleChapterIdRef.current = chunk.chapterId;
|
||||||
|
setVisibleChapterId(chunk.chapterId);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the user reaches the very bottom of the full strip, mark the
|
||||||
|
// last chapter as read (it never triggers the "crossed into next chunk" path).
|
||||||
|
if (settings.autoMarkRead) {
|
||||||
|
const isScrollable = el.scrollHeight > el.clientHeight + 4;
|
||||||
|
const atVeryBottom = isScrollable && el.scrollTop + el.clientHeight >= el.scrollHeight - 40;
|
||||||
|
if (atVeryBottom) {
|
||||||
|
const lastChunk = strip[strip.length - 1];
|
||||||
|
if (lastChunk && !markedReadRef.current.has(lastChunk.chapterId)) {
|
||||||
|
markedReadRef.current.add(lastChunk.chapterId);
|
||||||
|
gql(MARK_CHAPTER_READ, { id: lastChunk.chapterId, isRead: true }).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Append next chapter when within 300px of the bottom
|
// Append next chapter when within 300px of the bottom
|
||||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
|
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
|
||||||
if (!nearBottom) return;
|
if (!nearBottom) return;
|
||||||
@@ -690,7 +777,7 @@ export default function Reader() {
|
|||||||
el.removeEventListener("scroll", onScroll);
|
el.removeEventListener("scroll", onScroll);
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
};
|
};
|
||||||
}, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages, visibleChapterId]);
|
}, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages]);
|
||||||
|
|
||||||
// Reset scroll position when switching chapters in non-longstrip modes
|
// Reset scroll position when switching chapters in non-longstrip modes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -881,6 +968,7 @@ export default function Reader() {
|
|||||||
style={cssVars}
|
style={cssVars}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={handleTap}
|
onClick={handleTap}
|
||||||
|
onWheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === " " && style === "longstrip") {
|
if (e.key === " " && style === "longstrip") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -916,11 +1004,11 @@ export default function Reader() {
|
|||||||
) : (
|
) : (
|
||||||
pageReady && (
|
pageReady && (
|
||||||
<img
|
<img
|
||||||
key={pageNumber}
|
|
||||||
src={pageUrls[pageNumber - 1]}
|
src={pageUrls[pageNumber - 1]}
|
||||||
alt={`Page ${pageNumber}`}
|
alt={`Page ${pageNumber}`}
|
||||||
className={imgCls}
|
className={imgCls}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
style={{ transition: "opacity 0.1s ease" }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -928,10 +1016,10 @@ export default function Reader() {
|
|||||||
|
|
||||||
{/* ── Bottom nav ── */}
|
{/* ── Bottom nav ── */}
|
||||||
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
||||||
<button className={s.navBtn} onClick={goPrev} disabled={pageNumber === 1 && !adjacent.prev}>
|
<button className={s.navBtn} onClick={goPrev} disabled={loading || (pageNumber === 1 && !adjacent.prev)}>
|
||||||
<ArrowLeft size={13} weight="light" />
|
<ArrowLeft size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<button className={s.navBtn} onClick={goNext} disabled={pageNumber === lastPage && !adjacent.next}>
|
<button className={s.navBtn} onClick={goNext} disabled={loading || (pageNumber === lastPage && !adjacent.next)}>
|
||||||
<ArrowRight size={13} weight="light" />
|
<ArrowRight size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,79 @@
|
|||||||
|
/* ── Root ────────────────────────────────────────────────────────────────── */
|
||||||
.root {
|
.root {
|
||||||
display: flex; flex-direction: column; height: 100%;
|
display: flex; flex-direction: column; height: 100%;
|
||||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
overflow: hidden; animation: fadeIn 0.14s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||||
.header {
|
.header {
|
||||||
display: flex; align-items: center; gap: var(--sp-4);
|
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;
|
padding: var(--sp-3) var(--sp-6) var(--sp-3) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase; flex-shrink: 0;
|
text-transform: uppercase; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tab bar ─────────────────────────────────────────────────────────────── */
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 2px;
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md); padding: 2px;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||||
|
padding: 4px 10px; border-radius: var(--radius-sm); border: none;
|
||||||
|
background: none; color: var(--text-faint); cursor: pointer;
|
||||||
|
transition: background var(--t-base), color var(--t-base); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
.tabActive {
|
||||||
|
background: var(--accent-muted); color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
}
|
||||||
|
.tabActive:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Keyword tab bar area ────────────────────────────────────────────────── */
|
||||||
|
.keywordBar {
|
||||||
|
flex-shrink: 0; display: flex; flex-direction: column;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared search bar ───────────────────────────────────────────────────── */
|
||||||
.searchBar {
|
.searchBar {
|
||||||
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
border-radius: var(--radius-lg); padding: 0 var(--sp-3) 0 var(--sp-2);
|
border-radius: var(--radius-lg); padding: 0 var(--sp-3) 0 var(--sp-2);
|
||||||
transition: border-color var(--t-base);
|
transition: border-color var(--t-base);
|
||||||
|
margin: var(--sp-3) var(--sp-6);
|
||||||
}
|
}
|
||||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||||
|
|
||||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
|
||||||
.searchInput {
|
.searchInput {
|
||||||
flex: 1; background: none; border: none; outline: none;
|
flex: 1; background: none; border: none; outline: none;
|
||||||
color: var(--text-primary); font-size: var(--text-sm); padding: 8px 0;
|
color: var(--text-primary); font-size: var(--text-sm); padding: 8px 0;
|
||||||
}
|
}
|
||||||
.searchInput::placeholder { color: var(--text-faint); }
|
.searchInput::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.advancedBtn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||||||
|
background: none; border: 1px solid transparent;
|
||||||
|
color: var(--text-faint); cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.advancedBtn:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
||||||
|
.advancedBtnActive {
|
||||||
|
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
|
||||||
.searchBtn {
|
.searchBtn {
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
padding: 5px 12px; border-radius: var(--radius-md); flex-shrink: 0;
|
padding: 5px 12px; border-radius: var(--radius-md); flex-shrink: 0;
|
||||||
@@ -35,74 +84,108 @@
|
|||||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
.langBar {
|
.clearSearchBtn {
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
align-items: center;
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
flex-wrap: wrap;
|
font-size: 15px; line-height: 1;
|
||||||
gap: var(--sp-1);
|
color: var(--text-faint); background: var(--bg-overlay); border: none;
|
||||||
padding: var(--sp-2) var(--sp-6);
|
cursor: pointer; flex-shrink: 0; transition: color var(--t-base);
|
||||||
border-bottom: 1px solid var(--border-dim);
|
}
|
||||||
flex-shrink: 0;
|
.clearSearchBtn:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ── Advanced filter panel ───────────────────────────────────────────────── */
|
||||||
|
.advancedPanel {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
|
padding: 0 var(--sp-6) var(--sp-4);
|
||||||
|
animation: fadeIn 0.1s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.langBtn {
|
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
|
||||||
font-family: var(--font-ui);
|
.advancedTitle {
|
||||||
font-size: var(--text-2xs);
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
letter-spacing: var(--tracking-wider);
|
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
padding: 3px 8px;
|
}
|
||||||
border-radius: var(--radius-sm);
|
.advancedActions { display: flex; gap: var(--sp-3); }
|
||||||
border: 1px solid var(--border-dim);
|
.advancedLink {
|
||||||
background: none;
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
color: var(--text-faint);
|
letter-spacing: var(--tracking-wide); color: var(--accent-fg);
|
||||||
cursor: pointer;
|
background: none; border: none; cursor: pointer; padding: 0;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.advancedLink:hover { opacity: 0.7; }
|
||||||
|
|
||||||
|
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
.langFilterRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); padding: var(--sp-3) var(--sp-3) 0; }
|
||||||
|
|
||||||
|
.langChip {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wider); padding: 3px 8px;
|
||||||
|
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||||
|
background: none; color: var(--text-faint); cursor: pointer;
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.langBtn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
.langBtnActive {
|
.langChipActive {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.langBtnActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.sourceCount {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.results { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-6); }
|
.advancedDivider { height: 1px; background: var(--border-dim); margin: 0 calc(-1 * var(--sp-6)); }
|
||||||
|
.advancedCheck {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted); cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.checkbox { accent-color: var(--accent-fg); width: 13px; height: 13px; cursor: pointer; }
|
||||||
|
.advancedFooter {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
.advancedFooter strong { color: var(--text-muted); font-weight: var(--weight-medium); }
|
||||||
|
|
||||||
|
/* ── Keyword results list ────────────────────────────────────────────────── */
|
||||||
|
.results {
|
||||||
|
flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6);
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-6);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
.sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); }
|
.sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
|
||||||
.sourceHeader {
|
.sourceHeader { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.sourceIcon { width: 18px; height: 18px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
.sourceIcon { width: 18px; height: 18px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||||
.sourceName { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
.sourceName { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||||
.resultCount {
|
.sourceLang {
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||||
letter-spacing: var(--tracking-wide); margin-left: auto;
|
letter-spacing: var(--tracking-wider); padding: 1px 5px;
|
||||||
|
border: 1px solid var(--border-dim); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.resultCount {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto;
|
||||||
}
|
}
|
||||||
.sourceError { font-size: var(--text-xs); color: var(--color-error); padding: 0 var(--sp-1); }
|
.sourceError { font-size: var(--text-xs); color: var(--color-error); padding: 0 var(--sp-1); }
|
||||||
|
|
||||||
.sourceRow {
|
.sourceRow {
|
||||||
display: flex; gap: var(--sp-3); overflow-x: auto;
|
display: flex; gap: var(--sp-3); overflow-x: auto;
|
||||||
padding-bottom: var(--sp-2);
|
padding-bottom: var(--sp-2); scrollbar-width: thin;
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Shared manga card ───────────────────────────────────────────────────── */
|
||||||
.card {
|
.card {
|
||||||
flex-shrink: 0; width: 110px; background: none; border: none; padding: 0;
|
flex-shrink: 0; width: 110px;
|
||||||
|
background: none; border: none; padding: 0;
|
||||||
cursor: pointer; text-align: left;
|
cursor: pointer; text-align: left;
|
||||||
}
|
}
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
.card:hover .cover { filter: brightness(1.06); }
|
||||||
|
|
||||||
.coverWrap {
|
.coverWrap {
|
||||||
position: relative; aspect-ratio: 2/3; border-radius: var(--radius-md);
|
position: relative; aspect-ratio: 2/3;
|
||||||
overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim);
|
border-radius: var(--radius-md); overflow: hidden;
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
}
|
}
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
|
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
|
||||||
|
|
||||||
.inLibBadge {
|
.inLibBadge {
|
||||||
position: absolute; bottom: var(--sp-1); left: var(--sp-1);
|
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);
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
@@ -115,14 +198,145 @@
|
|||||||
line-height: var(--leading-snug);
|
line-height: var(--leading-snug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton cards ──────────────────────────────────────────────────────── */
|
||||||
.skCard { flex-shrink: 0; width: 110px; }
|
.skCard { flex-shrink: 0; width: 110px; }
|
||||||
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); width: 100%; }
|
||||||
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; }
|
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
.empty {
|
.empty {
|
||||||
flex: 1; display: flex; flex-direction: column;
|
flex: 1; display: flex; flex-direction: column;
|
||||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
align-items: center; justify-content: center; gap: var(--sp-2);
|
||||||
|
padding: var(--sp-6);
|
||||||
}
|
}
|
||||||
.emptyIcon { color: var(--text-faint); }
|
.emptyIcon { color: var(--text-faint); }
|
||||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); text-align: center; max-width: 280px; }
|
||||||
|
|
||||||
|
.advancedLinkStandalone {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); background: none; border: none;
|
||||||
|
cursor: pointer; padding: 0; margin-top: var(--sp-1);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.advancedLinkStandalone:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Split layout (tag + source tabs) ───────────────────────────────────── */
|
||||||
|
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||||
|
|
||||||
|
.splitSidebar {
|
||||||
|
width: 192px; flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitSearchWrap {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.splitSearchInput {
|
||||||
|
flex: 1; background: none; border: none; outline: none;
|
||||||
|
color: var(--text-primary); font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-ui); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
.splitSearchInput::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.splitList {
|
||||||
|
flex: 1; overflow-y: auto; padding: var(--sp-2);
|
||||||
|
display: flex; flex-direction: column; gap: 1px; scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitItem {
|
||||||
|
display: flex; align-items: center; width: 100%;
|
||||||
|
padding: 7px var(--sp-2); border-radius: var(--radius-md);
|
||||||
|
border: none; background: none;
|
||||||
|
color: var(--text-muted); font-size: var(--text-sm);
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
.splitItem:hover { background: var(--bg-overlay); color: var(--text-secondary); }
|
||||||
|
.splitItemActive { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
.splitItemActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.splitItemSource { gap: var(--sp-2); }
|
||||||
|
.splitItemLabel { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.splitSourceIcon { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.splitEmpty {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); padding: var(--sp-3) var(--sp-2);
|
||||||
|
}
|
||||||
|
.splitLoading {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Split right content ─────────────────────────────────────────────────── */
|
||||||
|
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
|
.splitContentHeader {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) var(--sp-5);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
.splitContentTitle {
|
||||||
|
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary); letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
.splitResultCount {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.splitSourceTitle {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sourceBrowseBar {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Grid (tag + source results) ─────────────────────────────────────────── */
|
||||||
|
.tagGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 9vw, 120px), 1fr));
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4) var(--sp-5);
|
||||||
|
overflow-y: auto; flex: 1;
|
||||||
|
align-content: start;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
/* In the grid, cards stretch to fill the column */
|
||||||
|
.tagGrid .card { width: auto; }
|
||||||
|
.tagGrid .skCard { width: auto; }
|
||||||
|
.tagGrid .skCover { width: 100%; }
|
||||||
|
|
||||||
|
/* ── Show more (tag grid & genre drill) ──────────────────────────────────── */
|
||||||
|
.showMoreCell {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-2) 0 var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showMoreBtn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 7px 20px; border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised); color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-dim); cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.showMoreBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
.showMoreBtn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.nsfwBadge {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); padding: 1px 5px;
|
||||||
|
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||||
|
color: var(--text-faint); flex-shrink: 0;
|
||||||
|
}
|
||||||
+689
-96
@@ -1,11 +1,19 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect, memo, useMemo } from "react";
|
||||||
import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react";
|
import {
|
||||||
|
MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
|
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
import s from "./Search.module.css";
|
import s from "./Search.module.css";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type SearchTab = "keyword" | "tag" | "source";
|
||||||
|
|
||||||
interface SourceResult {
|
interface SourceResult {
|
||||||
source: Source;
|
source: Source;
|
||||||
mangas: Manga[];
|
mangas: Manga[];
|
||||||
@@ -13,15 +21,30 @@ interface SourceResult {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONCURRENCY = 3;
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CONCURRENCY = 4;
|
||||||
|
const RESULTS_PER_SOURCE = 8;
|
||||||
|
|
||||||
|
const COMMON_GENRES = [
|
||||||
|
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
|
||||||
|
"Sci-Fi","Slice of Life","Horror","Mystery","Thriller","Sports",
|
||||||
|
"Supernatural","Mecha","Historical","Psychological","School Life",
|
||||||
|
"Shounen","Seinen","Josei","Shoujo","Isekai","Martial Arts",
|
||||||
|
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Concurrent fetch helper ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async function runConcurrent<T>(
|
async function runConcurrent<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
fn: (item: T) => Promise<void>
|
fn: (item: T) => Promise<void>,
|
||||||
|
signal: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
async function worker() {
|
async function worker() {
|
||||||
while (i < items.length) {
|
while (i < items.length) {
|
||||||
|
if (signal.aborted) return;
|
||||||
const item = items[i++];
|
const item = items[i++];
|
||||||
await fn(item).catch(() => {});
|
await fn(item).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -29,84 +52,280 @@ async function runConcurrent<T>(
|
|||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared card ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CoverImg = memo(function CoverImg({
|
||||||
|
src, alt, className,
|
||||||
|
}: { src: string; alt: string; className?: string }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
return (
|
||||||
|
<img src={src} alt={alt} className={className}
|
||||||
|
loading="lazy" decoding="async" onLoad={() => setLoaded(true)}
|
||||||
|
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function MangaCard({ manga, onClick }: { manga: Manga; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button className={s.card} onClick={onClick}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||||
|
{manga.inLibrary && <span className={s.inLibBadge}>Saved</span>}
|
||||||
|
</div>
|
||||||
|
<p className={s.cardTitle}>{manga.title}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GridSkeleton({ count = 18 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={s.tagGrid}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className={s.skCard} style={{ width: "auto" }}>
|
||||||
|
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
|
||||||
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowSkeleton({ count = 4 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={s.sourceRow}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className={s.skCard}>
|
||||||
|
<div className={["skeleton", s.skCover].join(" ")} />
|
||||||
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Root ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const [query, setQuery] = useState("");
|
const [tab, setTab] = useState<SearchTab>("keyword");
|
||||||
const [submitted, setSubmitted] = useState("");
|
|
||||||
const [results, setResults] = useState<SourceResult[]>([]);
|
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
|
||||||
|
const searchPrefill = useStore((st) => st.searchPrefill ?? "");
|
||||||
|
const setSearchPrefill = useStore((st) => st.setSearchPrefill);
|
||||||
|
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||||
|
|
||||||
const [allSources, setAllSources] = useState<Source[]>([]);
|
const [allSources, setAllSources] = useState<Source[]>([]);
|
||||||
const [loadingSources, setLoadingSources] = useState(false);
|
const [loadingSources, setLoadingSources] = useState(false);
|
||||||
const [activeLang, setActiveLang] = useState<string>("preferred");
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const setActiveManga = useStore((st) => st.setActiveManga);
|
const pendingPrefill = useRef<string>("");
|
||||||
const setNavPage = useStore((st) => st.setNavPage);
|
|
||||||
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
|
|
||||||
|
|
||||||
|
// Consume searchPrefill → route to keyword tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchPrefill) return;
|
||||||
|
pendingPrefill.current = searchPrefill;
|
||||||
|
setTab("keyword");
|
||||||
|
setSearchPrefill("");
|
||||||
|
}, [searchPrefill, setSearchPrefill]);
|
||||||
|
|
||||||
|
// Load sources once, shared across all tabs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingSources(true);
|
setLoadingSources(true);
|
||||||
|
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then((d) => setAllSources(d.sources.nodes.filter((s) => s.id !== "0")))
|
.then((d) => d.sources.nodes.filter((src) => src.id !== "0"))
|
||||||
|
)
|
||||||
|
.then(setAllSources)
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoadingSources(false));
|
.finally(() => setLoadingSources(false));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const langs = ["preferred", ...Array.from(new Set(allSources.map((s) => s.lang))).sort(), "all"];
|
const availableLangs = useMemo(() =>
|
||||||
|
Array.from(new Set<string>(allSources.map((s) => s.lang))).sort(), [allSources]);
|
||||||
const visibleSources = allSources.filter((src) => {
|
const hasMultipleLangs = availableLangs.length > 1;
|
||||||
if (activeLang === "all") return true;
|
|
||||||
if (activeLang === "preferred") return src.lang === preferredLang;
|
|
||||||
return src.lang === activeLang;
|
|
||||||
});
|
|
||||||
|
|
||||||
const runSearch = useCallback(async () => {
|
|
||||||
const q = query.trim();
|
|
||||||
if (!q || !visibleSources.length) return;
|
|
||||||
setSubmitted(q);
|
|
||||||
|
|
||||||
setResults(visibleSources.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
|
|
||||||
|
|
||||||
await runConcurrent(visibleSources, async (src) => {
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "SEARCH", page: 1, query: q,
|
|
||||||
});
|
|
||||||
setResults((prev) => prev.map((r) =>
|
|
||||||
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
|
|
||||||
));
|
|
||||||
} catch (e: any) {
|
|
||||||
setResults((prev) => prev.map((r) =>
|
|
||||||
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [query, visibleSources]);
|
|
||||||
|
|
||||||
function openManga(m: Manga) {
|
|
||||||
setActiveManga(m);
|
|
||||||
setNavPage("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasResults = results.some((r) => r.mangas.length > 0);
|
|
||||||
const allDone = results.every((r) => !r.loading);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root}>
|
<div className={s.root}>
|
||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<h1 className={s.heading}>Search</h1>
|
<h1 className={s.heading}>Search</h1>
|
||||||
|
<div className={s.tabs}>
|
||||||
|
<button className={[s.tab, tab === "keyword" ? s.tabActive : ""].join(" ")} onClick={() => setTab("keyword")}>
|
||||||
|
<MagnifyingGlass size={11} weight="bold" /> Keyword
|
||||||
|
</button>
|
||||||
|
<button className={[s.tab, tab === "tag" ? s.tabActive : ""].join(" ")} onClick={() => setTab("tag")}>
|
||||||
|
<Hash size={11} weight="bold" /> Tags
|
||||||
|
</button>
|
||||||
|
<button className={[s.tab, tab === "source" ? s.tabActive : ""].join(" ")} onClick={() => setTab("source")}>
|
||||||
|
<List size={11} weight="bold" /> Sources
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "keyword" && (
|
||||||
|
<KeywordTab
|
||||||
|
allSources={allSources}
|
||||||
|
loadingSources={loadingSources}
|
||||||
|
availableLangs={availableLangs}
|
||||||
|
hasMultipleLangs={hasMultipleLangs}
|
||||||
|
preferredLang={preferredLang}
|
||||||
|
pendingPrefill={pendingPrefill}
|
||||||
|
onMangaClick={setPreviewManga}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === "tag" && (
|
||||||
|
<TagTab
|
||||||
|
allSources={allSources}
|
||||||
|
loadingSources={loadingSources}
|
||||||
|
preferredLang={preferredLang}
|
||||||
|
onMangaClick={setPreviewManga}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === "source" && (
|
||||||
|
<SourceTab
|
||||||
|
allSources={allSources}
|
||||||
|
loadingSources={loadingSources}
|
||||||
|
availableLangs={availableLangs}
|
||||||
|
hasMultipleLangs={hasMultipleLangs}
|
||||||
|
onMangaClick={setPreviewManga}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyword tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function KeywordTab({
|
||||||
|
allSources, loadingSources, availableLangs, hasMultipleLangs,
|
||||||
|
preferredLang, pendingPrefill, onMangaClick,
|
||||||
|
}: {
|
||||||
|
allSources: Source[];
|
||||||
|
loadingSources: boolean;
|
||||||
|
availableLangs: string[];
|
||||||
|
hasMultipleLangs: boolean;
|
||||||
|
preferredLang: string;
|
||||||
|
pendingPrefill: React.MutableRefObject<string>;
|
||||||
|
onMangaClick: (m: Manga) => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [submitted, setSubmitted] = useState("");
|
||||||
|
const [results, setResults] = useState<SourceResult[]>([]);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [selectedLangs, setSelectedLangs] = useState<Set<string>>(new Set());
|
||||||
|
const [includeNsfw, setIncludeNsfw] = useState(false);
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const allSourcesRef = useRef<Source[]>([]);
|
||||||
|
const selectedLangsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => { allSourcesRef.current = allSources; }, [allSources]);
|
||||||
|
useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]);
|
||||||
|
|
||||||
|
// Set default lang selection once sources load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allSources.length) return;
|
||||||
|
const available = new Set(allSources.map((s) => s.lang));
|
||||||
|
setSelectedLangs(available.has(preferredLang)
|
||||||
|
? new Set([preferredLang])
|
||||||
|
: new Set(availableLangs.slice(0, 1))
|
||||||
|
);
|
||||||
|
}, [allSources]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Consume prefill once sources are ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadingSources || !pendingPrefill.current || submitted) return;
|
||||||
|
if (!allSourcesRef.current.length) return;
|
||||||
|
const q = pendingPrefill.current;
|
||||||
|
pendingPrefill.current = "";
|
||||||
|
setQuery(q);
|
||||||
|
doSearch(q);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [loadingSources]);
|
||||||
|
|
||||||
|
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||||
|
|
||||||
|
const getVisibleSources = useCallback((): Source[] => {
|
||||||
|
let filtered = allSourcesRef.current;
|
||||||
|
if (selectedLangsRef.current.size > 0)
|
||||||
|
filtered = filtered.filter((s) => selectedLangsRef.current.has(s.lang));
|
||||||
|
if (!includeNsfw)
|
||||||
|
filtered = filtered.filter((s) => !s.isNsfw);
|
||||||
|
return filtered;
|
||||||
|
}, [includeNsfw]);
|
||||||
|
|
||||||
|
const doSearch = useCallback(async (q: string) => {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const visible = getVisibleSources();
|
||||||
|
if (!visible.length) return;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
setSubmitted(trimmed);
|
||||||
|
setResults(visible.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
|
||||||
|
|
||||||
|
await runConcurrent(visible, async (src) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
setResults((prev) => prev.map((r) =>
|
||||||
|
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
|
||||||
|
));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
|
setResults((prev) => prev.map((r) =>
|
||||||
|
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
}, [getVisibleSources]);
|
||||||
|
|
||||||
|
function toggleLang(lang: string) {
|
||||||
|
setSelectedLangs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(lang)) { if (next.size === 1) return prev; next.delete(lang); }
|
||||||
|
else next.add(lang);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCount = getVisibleSources().length;
|
||||||
|
const hasResults = results.some((r) => r.mangas.length > 0);
|
||||||
|
const allDone = results.every((r) => !r.loading);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={s.keywordBar}>
|
||||||
<div className={s.searchBar}>
|
<div className={s.searchBar}>
|
||||||
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
|
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef} autoFocus
|
||||||
className={s.searchInput}
|
className={s.searchInput}
|
||||||
placeholder="Search across sources…"
|
placeholder="Search across sources…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && runSearch()}
|
onKeyDown={(e) => e.key === "Enter" && doSearch(query)}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
|
{hasMultipleLangs && (
|
||||||
|
<button
|
||||||
|
className={[s.advancedBtn, showAdvanced ? s.advancedBtnActive : ""].join(" ")}
|
||||||
|
onClick={() => setShowAdvanced((v) => !v)}
|
||||||
|
title="Language & filter options"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={s.searchBtn}
|
className={s.searchBtn}
|
||||||
onClick={runSearch}
|
onClick={() => doSearch(query)}
|
||||||
disabled={!query.trim() || loadingSources}
|
disabled={!query.trim() || loadingSources}
|
||||||
>
|
>
|
||||||
{loadingSources
|
{loadingSources
|
||||||
@@ -114,20 +333,36 @@ export default function Search() {
|
|||||||
: "Search"}
|
: "Search"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.langBar}>
|
{hasMultipleLangs && showAdvanced && (
|
||||||
{langs.map((l) => (
|
<div className={s.advancedPanel}>
|
||||||
<button
|
<div className={s.advancedHeader}>
|
||||||
key={l}
|
<span className={s.advancedTitle}>Languages</span>
|
||||||
onClick={() => setActiveLang(l)}
|
<div className={s.advancedActions}>
|
||||||
className={[s.langBtn, activeLang === l ? s.langBtnActive : ""].join(" ").trim()}
|
<button className={s.advancedLink} onClick={() => setSelectedLangs(new Set(availableLangs))}>All</button>
|
||||||
|
<button className={s.advancedLink} onClick={() => setSelectedLangs(new Set([preferredLang]))}>Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={s.langGrid}>
|
||||||
|
{availableLangs.map((lang) => (
|
||||||
|
<button key={lang}
|
||||||
|
className={[s.langChip, selectedLangs.has(lang) ? s.langChipActive : ""].join(" ")}
|
||||||
|
onClick={() => toggleLang(lang)}
|
||||||
>
|
>
|
||||||
{l === "preferred" ? `${preferredLang.toUpperCase()} (default)` : l === "all" ? "All" : l.toUpperCase()}
|
{lang === preferredLang ? `${lang.toUpperCase()} ★` : lang.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{visibleSources.length > 0 && (
|
</div>
|
||||||
<span className={s.sourceCount}>{visibleSources.length} sources</span>
|
<div className={s.advancedDivider} />
|
||||||
|
<label className={s.advancedCheck}>
|
||||||
|
<input type="checkbox" checked={includeNsfw}
|
||||||
|
onChange={(e) => setIncludeNsfw(e.target.checked)} className={s.checkbox} />
|
||||||
|
Include NSFW sources
|
||||||
|
</label>
|
||||||
|
<div className={s.advancedFooter}>
|
||||||
|
Searching <strong>{visibleCount}</strong> source{visibleCount !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,8 +371,15 @@ export default function Search() {
|
|||||||
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
||||||
<p className={s.emptyText}>Search across sources</p>
|
<p className={s.emptyText}>Search across sources</p>
|
||||||
<p className={s.emptyHint}>
|
<p className={s.emptyHint}>
|
||||||
Searching {visibleSources.length} {activeLang === "preferred" ? `${preferredLang.toUpperCase()}` : activeLang === "all" ? "" : activeLang.toUpperCase()} source{visibleSources.length !== 1 ? "s" : ""}.
|
{hasMultipleLangs
|
||||||
|
? `${visibleCount} source${visibleCount !== 1 ? "s" : ""} · ${selectedLangs.size} language${selectedLangs.size !== 1 ? "s" : ""}`
|
||||||
|
: `${visibleCount} source${visibleCount !== 1 ? "s" : ""}`}
|
||||||
</p>
|
</p>
|
||||||
|
{hasMultipleLangs && !showAdvanced && (
|
||||||
|
<button className={s.advancedLinkStandalone} onClick={() => setShowAdvanced(true)}>
|
||||||
|
<SlidersHorizontal size={12} weight="light" /> Adjust language filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -148,59 +390,410 @@ export default function Search() {
|
|||||||
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{results
|
{results
|
||||||
.filter((r) => r.mangas.length > 0 || r.loading || r.error)
|
.filter((r) => r.mangas.length > 0 || r.loading || r.error)
|
||||||
.map(({ source, mangas, loading, error }) => (
|
.map(({ source, mangas, loading, error }) => (
|
||||||
<div key={source.id} className={s.sourceSection}>
|
<div key={source.id} className={s.sourceSection}>
|
||||||
<div className={s.sourceHeader}>
|
<div className={s.sourceHeader}>
|
||||||
<img
|
<img src={thumbUrl(source.iconUrl)} alt={source.displayName} className={s.sourceIcon}
|
||||||
src={thumbUrl(source.iconUrl)}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
alt={source.displayName}
|
|
||||||
className={s.sourceIcon}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
||||||
/>
|
|
||||||
<span className={s.sourceName}>{source.displayName}</span>
|
<span className={s.sourceName}>{source.displayName}</span>
|
||||||
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
{hasMultipleLangs && <span className={s.sourceLang}>{source.lang.toUpperCase()}</span>}
|
||||||
{!loading && mangas.length > 0 && (
|
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
|
||||||
<span className={s.resultCount}>{mangas.length} results</span>
|
{!loading && mangas.length > 0 && <span className={s.resultCount}>{mangas.length} results</span>}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className={s.sourceError}>{error}</p>
|
<p className={s.sourceError}>{error}</p>
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className={s.sourceRow}>
|
<RowSkeleton />
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.skCard}>
|
|
||||||
<div className={["skeleton", s.skCover].join(" ")} />
|
|
||||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : mangas.length > 0 ? (
|
) : mangas.length > 0 ? (
|
||||||
<div className={s.sourceRow}>
|
<div className={s.sourceRow}>
|
||||||
{mangas.slice(0, 8).map((m) => (
|
{mangas.slice(0, RESULTS_PER_SOURCE).map((m) => (
|
||||||
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
|
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
|
||||||
{m.inLibrary && <span className={s.inLibBadge}>In Library</span>}
|
|
||||||
</div>
|
|
||||||
<p className={s.cardTitle}>{m.title}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{allDone && !hasResults && (
|
||||||
{allDone && !hasResults && submitted && (
|
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<p className={s.emptyText}>No results for "{submitted}"</p>
|
<p className={s.emptyText}>No results for "{submitted}"</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag tab ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TAG_PAGE_SIZE = 50; // items shown per "page"
|
||||||
|
const TAG_FETCH_PAGES = 3; // source pages to fetch per source on initial load
|
||||||
|
const TAG_MAX_SOURCES = 12; // max sources to query
|
||||||
|
|
||||||
|
function TagTab({
|
||||||
|
preferredLang, onMangaClick,
|
||||||
|
}: {
|
||||||
|
allSources: Source[];
|
||||||
|
loadingSources: boolean;
|
||||||
|
preferredLang: string;
|
||||||
|
onMangaClick: (m: Manga) => void;
|
||||||
|
}) {
|
||||||
|
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||||
|
const [tagResults, setTagResults] = useState<Manga[]>([]);
|
||||||
|
const [loadingTag, setLoadingTag] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(TAG_PAGE_SIZE);
|
||||||
|
const [tagFilter, setTagFilter] = useState("");
|
||||||
|
// Track next page to fetch per source for "load more from network"
|
||||||
|
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||||
|
const sourcesRef = useRef<Source[]>([]);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||||
|
|
||||||
|
async function drillTag(tag: string) {
|
||||||
|
if (tag === activeTag && !loadingTag) return;
|
||||||
|
setActiveTag(tag);
|
||||||
|
setTagResults([]);
|
||||||
|
setLoadingTag(true);
|
||||||
|
setVisibleCount(TAG_PAGE_SIZE);
|
||||||
|
nextPageRef.current = new Map();
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sources = await cache.get(CACHE_KEYS.SOURCES, () =>
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => d.sources.nodes.filter((s) => s.id !== "0"))
|
||||||
|
);
|
||||||
|
const deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES);
|
||||||
|
sourcesRef.current = deduped;
|
||||||
|
|
||||||
|
// Start all at -1; the fetch loop sets the real next page if hasNextPage is true
|
||||||
|
for (const src of deduped) {
|
||||||
|
nextPageRef.current.set(src.id, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream results in: fetch each source's pages concurrently, update state as each settles
|
||||||
|
await runConcurrent(deduped, async (src) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const pageResults: Manga[] = [];
|
||||||
|
// Fetch TAG_FETCH_PAGES pages in series per source
|
||||||
|
for (let page = 1; page <= TAG_FETCH_PAGES; page++) {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page, query: tag },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
pageResults.push(...d.fetchSourceManga.mangas);
|
||||||
|
if (!d.fetchSourceManga.hasNextPage) {
|
||||||
|
nextPageRef.current.set(src.id, -1); // no more pages
|
||||||
|
break;
|
||||||
|
} else if (page === TAG_FETCH_PAGES) {
|
||||||
|
// Still has more pages beyond what we fetched upfront
|
||||||
|
nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
break; // source error — move on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ctrl.signal.aborted && pageResults.length > 0) {
|
||||||
|
setTagResults((prev) => dedupeMangaById([...prev, ...pageResults]));
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) setLoadingTag(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (!activeTag || loadingMore) return;
|
||||||
|
|
||||||
|
// First check if we have more buffered results to show
|
||||||
|
if (visibleCount < tagResults.length) {
|
||||||
|
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise fetch next pages from sources
|
||||||
|
const sourcesToFetch = sourcesRef.current.filter(
|
||||||
|
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
||||||
|
);
|
||||||
|
if (sourcesToFetch.length === 0) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runConcurrent(sourcesToFetch, async (src) => {
|
||||||
|
const page = nextPageRef.current.get(src.id)!;
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page, query: activeTag },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
||||||
|
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) {
|
||||||
|
setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) {
|
||||||
|
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredGenres = useMemo(() => {
|
||||||
|
const q = tagFilter.trim().toLowerCase();
|
||||||
|
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
||||||
|
}, [tagFilter]);
|
||||||
|
|
||||||
|
const visibleResults = tagResults.slice(0, visibleCount);
|
||||||
|
const hasMore = visibleCount < tagResults.length ||
|
||||||
|
sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.splitRoot}>
|
||||||
|
<div className={s.splitSidebar}>
|
||||||
|
<div className={s.splitSearchWrap}>
|
||||||
|
<MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" />
|
||||||
|
<input
|
||||||
|
className={s.splitSearchInput}
|
||||||
|
placeholder="Filter tags…"
|
||||||
|
value={tagFilter}
|
||||||
|
onChange={(e) => setTagFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={s.splitList}>
|
||||||
|
{filteredGenres.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
className={[s.splitItem, activeTag === tag ? s.splitItemActive : ""].join(" ")}
|
||||||
|
onClick={() => drillTag(tag)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filteredGenres.length === 0 && <p className={s.splitEmpty}>No matching tags</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.splitContent}>
|
||||||
|
{!activeTag ? (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<Hash size={32} weight="light" className={s.emptyIcon} />
|
||||||
|
<p className={s.emptyText}>Browse by tag</p>
|
||||||
|
<p className={s.emptyHint}>Select a genre tag to see matching manga across your sources.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={s.splitContentHeader}>
|
||||||
|
<span className={s.splitContentTitle}>{activeTag}</span>
|
||||||
|
{loadingTag
|
||||||
|
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
: <span className={s.splitResultCount}>
|
||||||
|
{visibleResults.length}{tagResults.length > visibleCount ? `+` : ""} of {tagResults.length} results
|
||||||
|
</span>}
|
||||||
|
</div>
|
||||||
|
{loadingTag ? (
|
||||||
|
<GridSkeleton count={50} />
|
||||||
|
) : tagResults.length > 0 ? (
|
||||||
|
<div className={s.tagGrid}>
|
||||||
|
{visibleResults.map((m) => (
|
||||||
|
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||||
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<div className={s.showMoreCell}>
|
||||||
|
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||||
|
{loadingMore
|
||||||
|
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading…</>
|
||||||
|
: "Show more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<p className={s.emptyText}>No results for "{activeTag}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SourceTab({
|
||||||
|
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
|
||||||
|
}: {
|
||||||
|
allSources: Source[];
|
||||||
|
loadingSources: boolean;
|
||||||
|
availableLangs: string[];
|
||||||
|
hasMultipleLangs: boolean;
|
||||||
|
onMangaClick: (m: Manga) => void;
|
||||||
|
}) {
|
||||||
|
const [selectedLang, setSelectedLang] = useState<string>("all");
|
||||||
|
const [activeSource, setActiveSource] = useState<Source | null>(null);
|
||||||
|
const [browseResults, setBrowseResults] = useState<Manga[]>([]);
|
||||||
|
const [loadingBrowse, setLoadingBrowse] = useState(false);
|
||||||
|
const [browseQuery, setBrowseQuery] = useState("");
|
||||||
|
const [submitted, setSubmitted] = useState("");
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||||
|
|
||||||
|
const visibleSources = useMemo(() =>
|
||||||
|
selectedLang === "all" ? allSources : allSources.filter((s) => s.lang === selectedLang),
|
||||||
|
[allSources, selectedLang]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string) {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
setLoadingBrowse(true);
|
||||||
|
setBrowseResults([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type, page: 1, query: q ?? null },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
if (!ctrl.signal.aborted) setBrowseResults(d.fetchSourceManga.mangas);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) setLoadingBrowse(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSource(src: Source) {
|
||||||
|
setActiveSource(src);
|
||||||
|
setBrowseQuery("");
|
||||||
|
setSubmitted("");
|
||||||
|
fetchBrowse(src, "POPULAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
if (!activeSource || !browseQuery.trim()) return;
|
||||||
|
setSubmitted(browseQuery.trim());
|
||||||
|
fetchBrowse(activeSource, "SEARCH", browseQuery.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
setBrowseQuery("");
|
||||||
|
setSubmitted("");
|
||||||
|
if (activeSource) fetchBrowse(activeSource, "POPULAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.splitRoot}>
|
||||||
|
<div className={s.splitSidebar}>
|
||||||
|
{hasMultipleLangs && (
|
||||||
|
<div className={s.langFilterRow}>
|
||||||
|
{["all", ...availableLangs].map((lang) => (
|
||||||
|
<button key={lang}
|
||||||
|
className={[s.langChip, selectedLang === lang ? s.langChipActive : ""].join(" ")}
|
||||||
|
onClick={() => setSelectedLang(lang)}
|
||||||
|
>
|
||||||
|
{lang === "all" ? "All" : lang.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadingSources ? (
|
||||||
|
<div className={s.splitLoading}>
|
||||||
|
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.splitList}>
|
||||||
|
{visibleSources.map((src) => (
|
||||||
|
<button key={src.id}
|
||||||
|
className={[s.splitItem, s.splitItemSource, activeSource?.id === src.id ? s.splitItemActive : ""].join(" ")}
|
||||||
|
onClick={() => selectSource(src)}
|
||||||
|
>
|
||||||
|
<img src={thumbUrl(src.iconUrl)} alt="" className={s.splitSourceIcon}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span className={s.splitItemLabel}>{src.displayName}</span>
|
||||||
|
{src.isNsfw && <span className={s.nsfwBadge}>18+</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{visibleSources.length === 0 && <p className={s.splitEmpty}>No sources for this language</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.splitContent}>
|
||||||
|
{!activeSource ? (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<List size={32} weight="light" className={s.emptyIcon} />
|
||||||
|
<p className={s.emptyText}>Browse a source</p>
|
||||||
|
<p className={s.emptyHint}>Select a source to see its popular titles, or search within it.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={s.splitContentHeader}>
|
||||||
|
<div className={s.splitSourceTitle}>
|
||||||
|
<img src={thumbUrl(activeSource.iconUrl)} alt="" className={s.splitSourceIcon}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span className={s.splitContentTitle}>{activeSource.displayName}</span>
|
||||||
|
{loadingBrowse && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
||||||
|
{!loadingBrowse && browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>}
|
||||||
|
</div>
|
||||||
|
<div className={s.sourceBrowseBar}>
|
||||||
|
<div className={s.searchBar} style={{ flex: 1 }}>
|
||||||
|
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||||
|
<input
|
||||||
|
className={s.searchInput}
|
||||||
|
placeholder={`Search ${activeSource.displayName}…`}
|
||||||
|
value={browseQuery}
|
||||||
|
onChange={(e) => setBrowseQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
/>
|
||||||
|
{submitted && (
|
||||||
|
<button className={s.clearSearchBtn} onClick={clearSearch} title="Clear search">×</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingBrowse ? <GridSkeleton /> : browseResults.length > 0 ? (
|
||||||
|
<div className={s.tagGrid}>
|
||||||
|
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<p className={s.emptyText}>{submitted ? `No results for "${submitted}"` : "No results"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,6 +98,16 @@
|
|||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.genreClickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.genreClickable:hover {
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.sourceLabel {
|
.sourceLabel {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
@@ -111,11 +121,52 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: var(--leading-base);
|
line-height: var(--leading-base);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 8;
|
-webkit-line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.descriptionExpanded {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descriptionWrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descToggle {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.descToggle:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.genreToggle {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1px 6px;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.genreToggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
/* ── Progress ── */
|
/* ── Progress ── */
|
||||||
.progressSection {
|
.progressSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -230,10 +281,39 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-top: auto;
|
|
||||||
padding-top: var(--sp-2);
|
padding-top: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar mark-all quick actions ── */
|
||||||
|
.markAllRow {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markAllBtn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px var(--sp-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.markAllBtn:hover:not(:disabled) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
.markAllBtn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
/* ── Chapter list ── */
|
/* ── Chapter list ── */
|
||||||
.listWrap {
|
.listWrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -644,6 +724,18 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* In-progress progress fill bar (width set inline) */
|
||||||
|
.gridCellProgress {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
/* In-progress — accent highlight on bottom edge */
|
/* In-progress — accent highlight on bottom edge */
|
||||||
.gridCellInProgress {
|
.gridCellInProgress {
|
||||||
border-color: var(--accent-dim);
|
border-color: var(--accent-dim);
|
||||||
@@ -853,18 +945,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: var(--sp-2);
|
padding: 6px var(--sp-2);
|
||||||
padding: 7px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--color-error);
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--text-faint);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-error);
|
border: 1px solid var(--border-dim);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background var(--t-base);
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.deleteAllBtn:hover:not(:disabled) {
|
||||||
|
color: var(--color-error);
|
||||||
|
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||||
|
background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent));
|
||||||
}
|
}
|
||||||
.deleteAllBtn:hover:not(:disabled) { background: var(--color-error-bg); }
|
|
||||||
.deleteAllBtn:disabled { opacity: 0.4; cursor: default; }
|
.deleteAllBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
/* ── Danger item in dl dropdown ─────────────────────────────────────── */
|
/* ── Danger item in dl dropdown ─────────────────────────────────────── */
|
||||||
@@ -874,3 +971,100 @@
|
|||||||
.dlItemDanger:hover:not(:disabled) {
|
.dlItemDanger:hover:not(:disabled) {
|
||||||
background: var(--color-error-bg) !important;
|
background: var(--color-error-bg) !important;
|
||||||
}
|
}
|
||||||
|
/* ── Download dropdown extended: Next-N quick buttons + range picker ─────── */
|
||||||
|
.dlSectionLabel {
|
||||||
|
padding: 6px var(--sp-3) 2px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlNextRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px var(--sp-2) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlNextBtn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
.dlNextBtn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
.dlNextBtn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
.dlNextSub {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlDivider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-dim);
|
||||||
|
margin: var(--sp-1) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlRangeRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px var(--sp-2) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlRangeInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
outline: none;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.dlRangeInput:focus { border-color: var(--border-focus); }
|
||||||
|
.dlRangeInput::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.dlRangeSep {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlRangeGo {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dlRangeGo:hover:not(:disabled) { background: var(--accent-dim); }
|
||||||
|
.dlRangeGo:disabled { opacity: 0.3; cursor: default; }
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, BookmarkSimple, Download, CheckCircle,
|
ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle,
|
||||||
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
ArrowSquareOut, CircleNotch, Play,
|
||||||
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
||||||
List, SquaresFour, FolderSimplePlus, X, Trash,
|
List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import {
|
import {
|
||||||
@@ -11,12 +11,15 @@ import {
|
|||||||
UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS,
|
UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS,
|
||||||
ENQUEUE_CHAPTERS_DOWNLOAD,
|
ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||||
} from "../../lib/queries";
|
} from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import MigrateModal from "./MigrateModal";
|
import MigrateModal from "./MigrateModal";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
import s from "./SeriesDetail.module.css";
|
import s from "./SeriesDetail.module.css";
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatDate(ts: string | null | undefined): string {
|
function formatDate(ts: string | null | undefined): string {
|
||||||
if (!ts) return "";
|
if (!ts) return "";
|
||||||
const n = Number(ts);
|
const n = Number(ts);
|
||||||
@@ -33,16 +36,172 @@ interface CtxState {
|
|||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25;
|
const CHAPTERS_PER_PAGE = 25;
|
||||||
|
|
||||||
// ── Folder picker (icon button for list header) ───────────────────────────────
|
// How long before we consider a manga detail / chapter list stale and silently re-fetch.
|
||||||
|
// This prevents hammering the server when rapidly opening/closing while still keeping
|
||||||
|
// data fresh enough for normal use.
|
||||||
|
const MANGA_CACHE_TTL_MS = 5 * 60 * 1000; // 5 min — detail rarely changes mid-session
|
||||||
|
const CHAPTER_CACHE_TTL_MS = 2 * 60 * 1000; // 2 min — chapters update more often
|
||||||
|
|
||||||
|
// ── TTL-aware memory stores (cleared on page refresh, not persisted) ──────────
|
||||||
|
// These supplement the session `cache` with timestamp tracking so we know when
|
||||||
|
// to silently re-validate in the background.
|
||||||
|
const mangaDetailStore = new Map<number, { data: Manga; fetchedAt: number }>();
|
||||||
|
const chapterStore = new Map<number, { data: Chapter[]; fetchedAt: number }>();
|
||||||
|
|
||||||
|
// ── Download dropdown ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DownloadDropdownProps {
|
||||||
|
sortedChapters: Chapter[];
|
||||||
|
continueChapter: { chapter: Chapter; type: string } | null;
|
||||||
|
downloadedCount: number;
|
||||||
|
deletingAll: boolean;
|
||||||
|
onEnqueue: (ids: number[]) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadDropdown({
|
||||||
|
sortedChapters, continueChapter, downloadedCount, deletingAll,
|
||||||
|
onEnqueue, onDelete, onClose,
|
||||||
|
}: DownloadDropdownProps) {
|
||||||
|
const [rangeFrom, setRangeFrom] = useState("");
|
||||||
|
const [rangeTo, setRangeTo] = useState("");
|
||||||
|
const [showRange, setShowRange] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler, true);
|
||||||
|
return () => document.removeEventListener("mousedown", handler, true);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const continueIdx = continueChapter
|
||||||
|
? sortedChapters.indexOf(continueChapter.chapter)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
function enqueueNext(n: number) {
|
||||||
|
if (continueIdx < 0) return;
|
||||||
|
const ids = sortedChapters
|
||||||
|
.slice(continueIdx, continueIdx + n)
|
||||||
|
.filter((c) => !c.isDownloaded)
|
||||||
|
.map((c) => c.id);
|
||||||
|
onEnqueue(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueRange() {
|
||||||
|
const from = parseFloat(rangeFrom);
|
||||||
|
const to = parseFloat(rangeTo);
|
||||||
|
if (isNaN(from) || isNaN(to)) return;
|
||||||
|
const lo = Math.min(from, to), hi = Math.max(from, to);
|
||||||
|
const ids = sortedChapters
|
||||||
|
.filter((c) => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded)
|
||||||
|
.map((c) => c.id);
|
||||||
|
if (ids.length) onEnqueue(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadNotDl = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded);
|
||||||
|
const allNotDl = sortedChapters.filter((c) => !c.isDownloaded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.dlDropdown} ref={ref}>
|
||||||
|
{continueChapter && continueIdx >= 0 && (
|
||||||
|
<>
|
||||||
|
<p className={s.dlSectionLabel}>From Ch.{continueChapter.chapter.chapterNumber}</p>
|
||||||
|
<div className={s.dlNextRow}>
|
||||||
|
{[5, 10, 25].map((n) => {
|
||||||
|
const avail = sortedChapters
|
||||||
|
.slice(continueIdx, continueIdx + n)
|
||||||
|
.filter((c) => !c.isDownloaded).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
className={s.dlNextBtn}
|
||||||
|
disabled={avail === 0}
|
||||||
|
onClick={() => enqueueNext(n)}
|
||||||
|
>
|
||||||
|
<span>Next {n}</span>
|
||||||
|
<span className={s.dlNextSub}>{avail} new</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className={s.dlDivider} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className={s.dlItem} onClick={() => setShowRange((p) => !p)}>
|
||||||
|
<span>Custom range…</span>
|
||||||
|
<span className={s.dlItemSub}>Enter chapter numbers</span>
|
||||||
|
</button>
|
||||||
|
{showRange && (
|
||||||
|
<div className={s.dlRangeRow}>
|
||||||
|
<input
|
||||||
|
className={s.dlRangeInput}
|
||||||
|
placeholder="From"
|
||||||
|
value={rangeFrom}
|
||||||
|
onChange={(e) => setRangeFrom(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && enqueueRange()}
|
||||||
|
/>
|
||||||
|
<span className={s.dlRangeSep}>–</span>
|
||||||
|
<input
|
||||||
|
className={s.dlRangeInput}
|
||||||
|
placeholder="To"
|
||||||
|
value={rangeTo}
|
||||||
|
onChange={(e) => setRangeTo(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && enqueueRange()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={s.dlRangeGo}
|
||||||
|
disabled={!rangeFrom.trim() || !rangeTo.trim()}
|
||||||
|
onClick={enqueueRange}
|
||||||
|
>
|
||||||
|
Queue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={s.dlDivider} />
|
||||||
|
|
||||||
|
<button className={s.dlItem} onClick={() => onEnqueue(unreadNotDl.map((c) => c.id))}>
|
||||||
|
<span>Unread chapters</span>
|
||||||
|
<span className={s.dlItemSub}>{unreadNotDl.length} remaining</span>
|
||||||
|
</button>
|
||||||
|
<button className={s.dlItem} onClick={() => onEnqueue(allNotDl.map((c) => c.id))}>
|
||||||
|
<span>Download all</span>
|
||||||
|
<span className={s.dlItemSub}>{allNotDl.length} not yet downloaded</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{downloadedCount > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={s.dlDivider} />
|
||||||
|
<button
|
||||||
|
className={[s.dlItem, s.dlItemDanger].join(" ")}
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={deletingAll}
|
||||||
|
>
|
||||||
|
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
||||||
|
<span className={s.dlItemSub}>{downloadedCount} downloaded</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Folder picker ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function FolderPicker({ mangaId }: { mangaId: number }) {
|
function FolderPicker({ mangaId }: { mangaId: number }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const folders = useStore((st) => st.settings.folders);
|
const folders = useStore((st) => st.settings.folders);
|
||||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||||
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
||||||
const addFolder = useStore((st) => st.addFolder);
|
const addFolder = useStore((st) => st.addFolder);
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
|
|
||||||
const assigned = folders.filter((f) => f.mangaIds.includes(mangaId));
|
const assigned = folders.filter((f) => f.mangaIds.includes(mangaId));
|
||||||
const hasAssigned = assigned.length > 0;
|
const hasAssigned = assigned.length > 0;
|
||||||
@@ -134,16 +293,20 @@ function FolderPicker({ mangaId }: { mangaId: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SeriesDetail() {
|
export default function SeriesDetail() {
|
||||||
const activeManga = useStore((state) => state.activeManga);
|
const activeManga = useStore((state) => state.activeManga);
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const openReader = useStore((state) => state.openReader);
|
const openReader = useStore((state) => state.openReader);
|
||||||
const settings = useStore((state) => state.settings);
|
const settings = useStore((state) => state.settings);
|
||||||
const updateSettings = useStore((state) => state.updateSettings);
|
const updateSettings = useStore((state) => state.updateSettings);
|
||||||
|
const addToast = useStore((state) => state.addToast);
|
||||||
|
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||||
|
const setNavPage = useStore((state) => state.setNavPage);
|
||||||
|
|
||||||
const [manga, setManga] = useState<Manga | null>(activeManga);
|
const [manga, setManga] = useState<Manga | null>(null);
|
||||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||||
const [loadingManga, setLoadingManga] = useState(true);
|
const [loadingManga, setLoadingManga] = useState(false);
|
||||||
const [loadingChapters, setLoadingChapters] = useState(true);
|
const [loadingChapters, setLoadingChapters] = useState(true);
|
||||||
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
|
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
|
||||||
const [dlOpen, setDlOpen] = useState(false);
|
const [dlOpen, setDlOpen] = useState(false);
|
||||||
@@ -156,41 +319,146 @@ export default function SeriesDetail() {
|
|||||||
const [jumpInput, setJumpInput] = useState("");
|
const [jumpInput, setJumpInput] = useState("");
|
||||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [descExpanded, setDescExpanded] = useState(false);
|
||||||
|
const [genresExpanded, setGenresExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Track the abort controllers for in-flight requests so we can cancel on unmount/change
|
||||||
|
// Manga detail and chapters each get their own controller so they don't clobber each other
|
||||||
|
const mangaAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const chapterAbortRef = useRef<AbortController | null>(null);
|
||||||
|
// Track the manga ID we're currently loading to discard stale results
|
||||||
|
const loadingForRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const sortDir = settings.chapterSortDir;
|
const sortDir = settings.chapterSortDir;
|
||||||
|
|
||||||
|
// ── Manga detail: serve from TTL cache, silently re-validate if stale ──────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeManga) return;
|
if (!activeManga) return;
|
||||||
|
|
||||||
|
const mangaId = activeManga.id;
|
||||||
|
|
||||||
|
// Cancel any in-flight manga detail request from a previous manga
|
||||||
|
mangaAbortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
mangaAbortRef.current = ctrl;
|
||||||
|
loadingForRef.current = mangaId;
|
||||||
|
|
||||||
|
const cached = mangaDetailStore.get(mangaId);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
// Serve from memory immediately — no loading state, no flash
|
||||||
|
setManga(cached.data);
|
||||||
|
setLoadingManga(false);
|
||||||
|
|
||||||
|
// If cache is fresh enough, skip the network entirely
|
||||||
|
if (now - cached.fetchedAt < MANGA_CACHE_TTL_MS) return;
|
||||||
|
|
||||||
|
// Stale: re-validate silently in the background (no spinner)
|
||||||
|
gql<{ manga: Manga }>(GET_MANGA, { id: mangaId }, ctrl.signal)
|
||||||
|
.then((data) => {
|
||||||
|
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
|
||||||
|
mangaDetailStore.set(mangaId, { data: data.manga, fetchedAt: Date.now() });
|
||||||
|
setManga(data.manga);
|
||||||
|
if (data.manga.source?.id) recordSourceAccess(data.manga.source.id);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing cached — show skeleton and fetch
|
||||||
setLoadingManga(true);
|
setLoadingManga(true);
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id: activeManga.id })
|
gql<{ manga: Manga }>(GET_MANGA, { id: mangaId }, ctrl.signal)
|
||||||
.then((data) => setManga(data.manga))
|
.then((data) => {
|
||||||
.catch(console.error)
|
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
|
||||||
.finally(() => setLoadingManga(false));
|
mangaDetailStore.set(mangaId, { data: data.manga, fetchedAt: Date.now() });
|
||||||
|
setManga(data.manga);
|
||||||
|
if (data.manga.source?.id) recordSourceAccess(data.manga.source.id);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||||
|
.finally(() => {
|
||||||
|
if (!ctrl.signal.aborted && loadingForRef.current === mangaId) setLoadingManga(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { ctrl.abort(); mangaAbortRef.current = null; };
|
||||||
}, [activeManga?.id]);
|
}, [activeManga?.id]);
|
||||||
|
|
||||||
const loadChapters = useCallback((mangaId: number) => {
|
// ── Chapter loading: cache-first, background refresh only when stale ────────
|
||||||
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId })
|
const applyChapters = useCallback((nodes: Chapter[]) => {
|
||||||
.then((data) => {
|
const sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
setChapters(sorted);
|
setChapters(sorted);
|
||||||
return sorted;
|
return sorted;
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeManga) return;
|
if (!activeManga) return;
|
||||||
setLoadingChapters(true);
|
|
||||||
setChapters([]);
|
const mangaId = activeManga.id;
|
||||||
setChapterPage(1);
|
setChapterPage(1);
|
||||||
|
|
||||||
loadChapters(activeManga.id)
|
// Cancel any previous in-flight chapter requests
|
||||||
.catch(console.error)
|
chapterAbortRef.current?.abort();
|
||||||
.finally(() => setLoadingChapters(false));
|
const ctrl = new AbortController();
|
||||||
|
chapterAbortRef.current = ctrl;
|
||||||
|
loadingForRef.current = mangaId;
|
||||||
|
|
||||||
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
const cached = chapterStore.get(mangaId);
|
||||||
.then(() => loadChapters(activeManga.id))
|
const now = Date.now();
|
||||||
.catch(console.error);
|
|
||||||
}, [activeManga?.id]);
|
if (cached) {
|
||||||
|
// Show cached data instantly
|
||||||
|
applyChapters(cached.data);
|
||||||
|
setLoadingChapters(false);
|
||||||
|
|
||||||
|
// Fresh enough — don't touch the network at all
|
||||||
|
if (now - cached.fetchedAt < CHAPTER_CACHE_TTL_MS) return;
|
||||||
|
|
||||||
|
// Stale — silently re-validate: fetch from source then re-read local DB
|
||||||
|
// We don't clear the chapter list while this happens (no flicker)
|
||||||
|
gql(FETCH_CHAPTERS, { mangaId }, ctrl.signal)
|
||||||
|
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal))
|
||||||
|
.then((data) => {
|
||||||
|
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
|
||||||
|
chapterStore.set(mangaId, { data: data.chapters.nodes, fetchedAt: Date.now() });
|
||||||
|
applyChapters(data.chapters.nodes);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing cached — show skeleton, load local DB first (fast), then source
|
||||||
|
setChapters([]);
|
||||||
|
setLoadingChapters(true);
|
||||||
|
|
||||||
|
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal)
|
||||||
|
.then((data) => {
|
||||||
|
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
|
||||||
|
// Show local DB result immediately so the user isn't staring at a spinner
|
||||||
|
applyChapters(data.chapters.nodes);
|
||||||
|
setLoadingChapters(false);
|
||||||
|
|
||||||
|
// Now silently fetch from the source to pick up any new chapters
|
||||||
|
return gql(FETCH_CHAPTERS, { mangaId }, ctrl.signal)
|
||||||
|
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal))
|
||||||
|
.then((fresh) => {
|
||||||
|
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
|
||||||
|
chapterStore.set(mangaId, { data: fresh.chapters.nodes, fetchedAt: Date.now() });
|
||||||
|
applyChapters(fresh.chapters.nodes);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
|
console.error(e);
|
||||||
|
setLoadingChapters(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { ctrl.abort(); chapterAbortRef.current = null; };
|
||||||
|
}, [activeManga?.id, applyChapters]);
|
||||||
|
|
||||||
|
// ── Derived state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const sortedChapters = useMemo(() =>
|
const sortedChapters = useMemo(() =>
|
||||||
sortDir === "desc" ? [...chapters].reverse() : [...chapters],
|
sortDir === "desc" ? [...chapters].reverse() : [...chapters],
|
||||||
@@ -202,7 +470,6 @@ export default function SeriesDetail() {
|
|||||||
(chapterPage - 1) * CHAPTERS_PER_PAGE,
|
(chapterPage - 1) * CHAPTERS_PER_PAGE,
|
||||||
chapterPage * CHAPTERS_PER_PAGE
|
chapterPage * CHAPTERS_PER_PAGE
|
||||||
);
|
);
|
||||||
|
|
||||||
const readCount = chapters.filter((c) => c.isRead).length;
|
const readCount = chapters.filter((c) => c.isRead).length;
|
||||||
const totalCount = chapters.length;
|
const totalCount = chapters.length;
|
||||||
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
|
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
|
||||||
@@ -219,39 +486,88 @@ export default function SeriesDetail() {
|
|||||||
return { chapter: asc[0], type: "reread" as const };
|
return { chapter: asc[0], type: "reread" as const };
|
||||||
}, [chapters]);
|
}, [chapters]);
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function toggleLibrary() {
|
async function toggleLibrary() {
|
||||||
if (!manga) return;
|
if (!manga) return;
|
||||||
setTogglingLibrary(true);
|
setTogglingLibrary(true);
|
||||||
const next = !manga.inLibrary;
|
const next = !manga.inLibrary;
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||||
setManga((prev) => prev ? { ...prev, inLibrary: next } : prev);
|
const updated = { ...manga, inLibrary: next };
|
||||||
|
setManga(updated);
|
||||||
|
// Update the detail cache so re-open reflects the new state
|
||||||
|
if (mangaDetailStore.has(manga.id)) {
|
||||||
|
const entry = mangaDetailStore.get(manga.id)!;
|
||||||
|
mangaDetailStore.set(manga.id, { ...entry, data: updated });
|
||||||
|
}
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
setTogglingLibrary(false);
|
setTogglingLibrary(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reloadChapters = useCallback((mangaId: number, signal?: AbortSignal) => {
|
||||||
|
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, signal)
|
||||||
|
.then((data) => {
|
||||||
|
chapterStore.set(mangaId, { data: data.chapters.nodes, fetchedAt: Date.now() });
|
||||||
|
applyChapters(data.chapters.nodes);
|
||||||
|
});
|
||||||
|
}, [applyChapters]);
|
||||||
|
|
||||||
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
|
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error);
|
await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error);
|
||||||
|
addToast({ kind: "download", title: "Download queued", body: chapter.name });
|
||||||
setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; });
|
setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; });
|
||||||
if (activeManga) loadChapters(activeManga.id);
|
if (activeManga) reloadChapters(activeManga.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueMultiple(chapterIds: number[]) {
|
||||||
|
if (!chapterIds.length) return;
|
||||||
|
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
||||||
|
addToast({
|
||||||
|
kind: "download",
|
||||||
|
title: "Download queued",
|
||||||
|
body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added to queue`,
|
||||||
|
});
|
||||||
|
if (activeManga) reloadChapters(activeManga.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(chapterId: number, isRead: boolean) {
|
async function markRead(chapterId: number, isRead: boolean) {
|
||||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||||
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isRead } : c));
|
setChapters((prev) => {
|
||||||
|
const updated = prev.map((c) => c.id === chapterId ? { ...c, isRead } : c);
|
||||||
|
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllAboveRead(indexInSorted: number) {
|
async function markBulk(ids: number[], isRead: boolean) {
|
||||||
const targets = sortedChapters.slice(0, indexInSorted + 1);
|
|
||||||
const ids = targets.filter((c) => !c.isRead).map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
await gql(MARK_CHAPTERS_READ, { ids, isRead: true }).catch(console.error);
|
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
||||||
setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead: true } : c));
|
setChapters((prev) => {
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
const updated = prev.map((c) => idSet.has(c.id) ? { ...c, isRead } : c);
|
||||||
|
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markAllAboveRead = (i: number) =>
|
||||||
|
markBulk(sortedChapters.slice(0, i + 1).filter((c) => !c.isRead).map((c) => c.id), true);
|
||||||
|
const markAllBelowRead = (i: number) =>
|
||||||
|
markBulk(sortedChapters.slice(i).filter((c) => !c.isRead).map((c) => c.id), true);
|
||||||
|
const markAllAboveUnread = (i: number) =>
|
||||||
|
markBulk(sortedChapters.slice(0, i + 1).filter((c) => c.isRead).map((c) => c.id), false);
|
||||||
|
const markAllBelowUnread = (i: number) =>
|
||||||
|
markBulk(sortedChapters.slice(i).filter((c) => c.isRead).map((c) => c.id), false);
|
||||||
|
|
||||||
async function deleteDownloaded(chapterId: number) {
|
async function deleteDownloaded(chapterId: number) {
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
||||||
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c));
|
setChapters((prev) => {
|
||||||
|
const updated = prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c);
|
||||||
|
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAllDownloads() {
|
async function deleteAllDownloads() {
|
||||||
@@ -259,13 +575,24 @@ export default function SeriesDetail() {
|
|||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
setDeletingAll(true);
|
setDeletingAll(true);
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
||||||
setChapters((prev) => prev.map((c) => ({ ...c, isDownloaded: false })));
|
setChapters((prev) => {
|
||||||
|
const updated = prev.map((c) => ({ ...c, isDownloaded: false }));
|
||||||
|
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
setDeletingAll(false);
|
setDeletingAll(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
async function refreshChapters() {
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
if (!activeManga || refreshing) return;
|
||||||
if (activeManga) loadChapters(activeManga.id);
|
setRefreshing(true);
|
||||||
|
// Force-invalidate the chapter cache for this manga so we get a fresh fetch
|
||||||
|
chapterStore.delete(activeManga.id);
|
||||||
|
await gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
||||||
|
.then(() => reloadChapters(activeManga.id))
|
||||||
|
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
|
||||||
|
.catch((e) => addToast({ kind: "error", title: "Refresh failed", body: e?.message ?? String(e) }))
|
||||||
|
.finally(() => setRefreshing(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) {
|
function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) {
|
||||||
@@ -274,19 +601,50 @@ export default function SeriesDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] {
|
function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] {
|
||||||
|
const aboveItems = sortedChapters.slice(0, indexInSorted + 1);
|
||||||
|
const belowItems = sortedChapters.slice(indexInSorted);
|
||||||
|
const unreadAbove = aboveItems.filter((c) => !c.isRead).length;
|
||||||
|
const unreadBelow = belowItems.filter((c) => !c.isRead).length;
|
||||||
|
const readAbove = aboveItems.filter((c) => c.isRead).length;
|
||||||
|
const readBelow = belowItems.filter((c) => c.isRead).length;
|
||||||
|
const lastIdx = sortedChapters.length - 1;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: ch.isRead ? "Mark as unread" : "Mark as read",
|
label: ch.isRead ? "Mark as unread" : "Mark as read",
|
||||||
|
icon: ch.isRead ? <Circle size={13} weight="light" /> : <CheckCircle size={13} weight="light" />,
|
||||||
onClick: () => markRead(ch.id, !ch.isRead),
|
onClick: () => markRead(ch.id, !ch.isRead),
|
||||||
},
|
},
|
||||||
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: "Mark all above as read",
|
label: "Mark above as read",
|
||||||
|
icon: <CheckCircle size={13} weight="duotone" />,
|
||||||
onClick: () => markAllAboveRead(indexInSorted),
|
onClick: () => markAllAboveRead(indexInSorted),
|
||||||
disabled: indexInSorted === 0,
|
disabled: indexInSorted === 0 || unreadAbove === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mark above as unread",
|
||||||
|
icon: <Circle size={13} weight="duotone" />,
|
||||||
|
onClick: () => markAllAboveUnread(indexInSorted),
|
||||||
|
disabled: indexInSorted === 0 || readAbove === 0,
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "Mark below as read",
|
||||||
|
icon: <CheckCircle size={13} weight="duotone" />,
|
||||||
|
onClick: () => markAllBelowRead(indexInSorted),
|
||||||
|
disabled: indexInSorted === lastIdx || unreadBelow === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mark below as unread",
|
||||||
|
icon: <Circle size={13} weight="duotone" />,
|
||||||
|
onClick: () => markAllBelowUnread(indexInSorted),
|
||||||
|
disabled: indexInSorted === lastIdx || readBelow === 0,
|
||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: ch.isDownloaded ? "Delete download" : "Download",
|
label: ch.isDownloaded ? "Delete download" : "Download",
|
||||||
|
icon: ch.isDownloaded ? <Trash size={13} weight="light" /> : <Download size={13} weight="light" />,
|
||||||
onClick: () => ch.isDownloaded
|
onClick: () => ch.isDownloaded
|
||||||
? deleteDownloaded(ch.id)
|
? deleteDownloaded(ch.id)
|
||||||
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
|
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
|
||||||
@@ -294,31 +652,48 @@ export default function SeriesDetail() {
|
|||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: "Download all from here",
|
label: "Download next 5 from here",
|
||||||
|
icon: <DownloadSimple size={13} weight="light" />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const fromHere = sortedChapters
|
const ids = sortedChapters
|
||||||
|
.slice(indexInSorted, indexInSorted + 5)
|
||||||
|
.filter((c) => !c.isDownloaded)
|
||||||
|
.map((c) => c.id);
|
||||||
|
enqueueMultiple(ids);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Download all from here",
|
||||||
|
icon: <DownloadSimple size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const ids = sortedChapters
|
||||||
.slice(indexInSorted)
|
.slice(indexInSorted)
|
||||||
.filter((c) => !c.isDownloaded)
|
.filter((c) => !c.isDownloaded)
|
||||||
.map((c) => c.id);
|
.map((c) => c.id);
|
||||||
enqueueMultiple(fromHere);
|
enqueueMultiple(ids);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Early exit ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (!activeManga) return null;
|
if (!activeManga) return null;
|
||||||
|
|
||||||
const statusLabel = manga?.status
|
const statusLabel = manga?.status
|
||||||
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
|
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root} onContextMenu={(e) => e.preventDefault()}>
|
<div className={s.root} onContextMenu={(e) => e.preventDefault()}>
|
||||||
|
|
||||||
{/* ── Sidebar ── */}
|
{/* ── Sidebar ── */}
|
||||||
<div className={s.sidebar}>
|
<div className={s.sidebar}>
|
||||||
<button className={s.back} onClick={() => setActiveManga(null)}>
|
<button className={s.back} onClick={() => setActiveManga(null)}>
|
||||||
<ArrowLeft size={13} weight="light" />
|
<ArrowLeft size={13} weight="light" />
|
||||||
<span>Library</span>
|
<span>Back</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={s.coverWrap}>
|
<div className={s.coverWrap}>
|
||||||
@@ -344,22 +719,54 @@ export default function SeriesDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{statusLabel && (
|
{statusLabel && (
|
||||||
<span className={[s.statusBadge, manga?.status === "ONGOING" ? s.statusOngoing : s.statusEnded].join(" ").trim()}>
|
<span className={[
|
||||||
|
s.statusBadge,
|
||||||
|
manga?.status === "ONGOING" ? s.statusOngoing : s.statusEnded,
|
||||||
|
].join(" ").trim()}>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{manga?.genre && manga.genre.length > 0 && (
|
{manga?.genre && manga.genre.length > 0 && (
|
||||||
<div className={s.genres}>
|
<div className={s.genres}>
|
||||||
{manga.genre.map((g) => <span key={g} className={s.genre}>{g}</span>)}
|
{(genresExpanded ? manga.genre : manga.genre.slice(0, 5)).map((g) => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
className={[s.genre, s.genreClickable].join(" ")}
|
||||||
|
title={`Filter library by "${g}"`}
|
||||||
|
onClick={() => {
|
||||||
|
setGenreFilter(g);
|
||||||
|
setNavPage("explore");
|
||||||
|
setActiveManga(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{manga.genre.length > 5 && (
|
||||||
|
<button className={s.genreToggle} onClick={() => setGenresExpanded((p) => !p)}>
|
||||||
|
{genresExpanded ? "less" : `+${manga.genre.length - 5}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{manga?.description && <p className={s.description}>{manga.description}</p>}
|
{manga?.description && (
|
||||||
|
<div className={s.descriptionWrap}>
|
||||||
|
<p className={[s.description, descExpanded ? s.descriptionExpanded : ""].join(" ")}>
|
||||||
|
{manga.description}
|
||||||
|
</p>
|
||||||
|
{manga.description.length > 120 && (
|
||||||
|
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
|
||||||
|
{descExpanded ? "Less" : "More"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress */}
|
||||||
{totalCount > 0 && (
|
{totalCount > 0 && (
|
||||||
<div className={s.progressSection}>
|
<div className={s.progressSection}>
|
||||||
<div className={s.progressHeader}>
|
<div className={s.progressHeader}>
|
||||||
@@ -389,8 +796,6 @@ export default function SeriesDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Folder picker moved to chapter list header */}
|
|
||||||
|
|
||||||
{continueChapter && (
|
{continueChapter && (
|
||||||
<button
|
<button
|
||||||
className={s.readBtn}
|
className={s.readBtn}
|
||||||
@@ -412,14 +817,23 @@ export default function SeriesDetail() {
|
|||||||
|
|
||||||
<p className={s.chapterCount}>
|
<p className={s.chapterCount}>
|
||||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||||
|
{readCount > 0 && ` · ${readCount} read`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* ── Details (collapsible) ── */}
|
{/* Source info — collapsible details */}
|
||||||
{!loadingManga && manga?.source && (
|
{!loadingManga && manga?.source && (
|
||||||
<div className={s.detailsSection}>
|
<div className={s.detailsSection}>
|
||||||
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
|
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
|
||||||
<span>Details</span>
|
<span>Details</span>
|
||||||
<CaretDown size={11} weight="light" className={detailsOpen ? s.caretOpen : s.caretClosed} />
|
<CaretDown
|
||||||
|
size={11}
|
||||||
|
weight="light"
|
||||||
|
style={{
|
||||||
|
transform: detailsOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||||
|
transition: "transform 0.15s ease",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
{detailsOpen && (
|
{detailsOpen && (
|
||||||
<div className={s.detailsBody}>
|
<div className={s.detailsBody}>
|
||||||
@@ -427,20 +841,36 @@ export default function SeriesDetail() {
|
|||||||
<span className={s.detailKey}>Source</span>
|
<span className={s.detailKey}>Source</span>
|
||||||
<span className={s.detailVal}>{manga.source.displayName}</span>
|
<span className={s.detailVal}>{manga.source.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{manga.status && (
|
||||||
<div className={s.detailRow}>
|
<div className={s.detailRow}>
|
||||||
<span className={s.detailKey}>Language</span>
|
<span className={s.detailKey}>Status</span>
|
||||||
<span className={s.detailVal}>{manga.source.name.match(/\(([^)]+)\)$/)?.[1] ?? "—"}</span>
|
<span className={s.detailVal}>
|
||||||
|
{manga.status.charAt(0) + manga.status.slice(1).toLowerCase()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{manga.author && (
|
||||||
<div className={s.detailRow}>
|
<div className={s.detailRow}>
|
||||||
<span className={s.detailKey}>Source ID</span>
|
<span className={s.detailKey}>Author</span>
|
||||||
<span className={[s.detailVal, s.detailMono].join(" ")}>{manga.source.id}</span>
|
<span className={s.detailVal}>{manga.author}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{manga.artist && manga.artist !== manga.author && (
|
||||||
|
<div className={s.detailRow}>
|
||||||
|
<span className={s.detailKey}>Artist</span>
|
||||||
|
<span className={s.detailVal}>{manga.artist}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<div className={s.detailRow}>
|
||||||
|
<span className={s.detailKey}>Progress</span>
|
||||||
|
<span className={s.detailVal}>{readCount} / {totalCount} read</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
|
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
|
||||||
<ArrowsClockwise size={12} weight="light" />
|
<ArrowsClockwise size={12} weight="light" />
|
||||||
Switch source
|
Switch source
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Delete all downloads */}
|
|
||||||
{downloadedCount > 0 && (
|
{downloadedCount > 0 && (
|
||||||
<button
|
<button
|
||||||
className={s.deleteAllBtn}
|
className={s.deleteAllBtn}
|
||||||
@@ -448,13 +878,20 @@ export default function SeriesDetail() {
|
|||||||
disabled={deletingAll}
|
disabled={deletingAll}
|
||||||
>
|
>
|
||||||
<Trash size={12} weight="light" />
|
<Trash size={12} weight="light" />
|
||||||
{deletingAll ? "Deleting…" : `Delete all downloads (${downloadedCount})`}
|
{deletingAll ? "Deleting…" : `Delete downloads (${downloadedCount})`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{manga && !manga.source && (
|
||||||
|
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
|
||||||
|
<ArrowsClockwise size={12} weight="light" />
|
||||||
|
Switch source
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Chapter list ── */}
|
{/* ── Chapter list ── */}
|
||||||
@@ -471,8 +908,7 @@ export default function SeriesDetail() {
|
|||||||
>
|
>
|
||||||
{sortDir === "desc"
|
{sortDir === "desc"
|
||||||
? <SortDescending size={14} weight="light" />
|
? <SortDescending size={14} weight="light" />
|
||||||
: <SortAscending size={14} weight="light" />
|
: <SortAscending size={14} weight="light" />}
|
||||||
}
|
|
||||||
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
|
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -489,7 +925,14 @@ export default function SeriesDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.listHeaderRight}>
|
<div className={s.listHeaderRight}>
|
||||||
{/* Folder picker */}
|
<button
|
||||||
|
className={s.viewToggleBtn}
|
||||||
|
onClick={refreshChapters}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="Refresh chapters from source"
|
||||||
|
>
|
||||||
|
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
{activeManga && <FolderPicker mangaId={activeManga.id} />}
|
{activeManga && <FolderPicker mangaId={activeManga.id} />}
|
||||||
|
|
||||||
{/* Jump to chapter */}
|
{/* Jump to chapter */}
|
||||||
@@ -536,50 +979,15 @@ export default function SeriesDetail() {
|
|||||||
<Download size={13} weight="light" />
|
<Download size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
{dlOpen && (
|
{dlOpen && (
|
||||||
<div className={s.dlDropdown}>
|
<DownloadDropdown
|
||||||
{continueChapter && (
|
sortedChapters={sortedChapters}
|
||||||
<button className={s.dlItem}
|
continueChapter={continueChapter}
|
||||||
onClick={() => {
|
downloadedCount={downloadedCount}
|
||||||
const from = sortedChapters.indexOf(continueChapter.chapter);
|
deletingAll={deletingAll}
|
||||||
const ids = sortedChapters.slice(from).filter((c) => !c.isDownloaded).map((c) => c.id);
|
onEnqueue={(ids) => { enqueueMultiple(ids); setDlOpen(false); }}
|
||||||
enqueueMultiple(ids);
|
onDelete={() => { deleteAllDownloads(); setDlOpen(false); }}
|
||||||
setDlOpen(false);
|
onClose={() => setDlOpen(false)}
|
||||||
}}>
|
/>
|
||||||
<span>From current</span>
|
|
||||||
<span className={s.dlItemSub}>Ch.{continueChapter.chapter.chapterNumber} onwards</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className={s.dlItem}
|
|
||||||
onClick={() => {
|
|
||||||
const ids = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).map((c) => c.id);
|
|
||||||
enqueueMultiple(ids);
|
|
||||||
setDlOpen(false);
|
|
||||||
}}>
|
|
||||||
<span>Unread chapters</span>
|
|
||||||
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).length} remaining</span>
|
|
||||||
</button>
|
|
||||||
<button className={s.dlItem}
|
|
||||||
onClick={() => {
|
|
||||||
const ids = sortedChapters.filter((c) => !c.isDownloaded).map((c) => c.id);
|
|
||||||
enqueueMultiple(ids);
|
|
||||||
setDlOpen(false);
|
|
||||||
}}>
|
|
||||||
<span>Download all</span>
|
|
||||||
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span>
|
|
||||||
</button>
|
|
||||||
{downloadedCount > 0 && (
|
|
||||||
<>
|
|
||||||
<div style={{ height: 1, background: "var(--border-dim)", margin: "var(--sp-1) var(--sp-2)" }} />
|
|
||||||
<button className={[s.dlItem, s.dlItemDanger].join(" ")}
|
|
||||||
onClick={() => { deleteAllDownloads(); setDlOpen(false); }}
|
|
||||||
disabled={deletingAll}
|
|
||||||
>
|
|
||||||
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
|
||||||
<span className={s.dlItemSub}>{downloadedCount} downloaded</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -619,8 +1027,7 @@ export default function SeriesDetail() {
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
sortedChapters.map((ch) => {
|
sortedChapters.map((ch, idxInSorted) => {
|
||||||
const idxInSorted = sortedChapters.indexOf(ch);
|
|
||||||
const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0;
|
const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -655,10 +1062,13 @@ export default function SeriesDetail() {
|
|||||||
pageChapters.map((ch) => {
|
pageChapters.map((ch) => {
|
||||||
const idxInSorted = sortedChapters.indexOf(ch);
|
const idxInSorted = sortedChapters.indexOf(ch);
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={ch.id}
|
key={ch.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()}
|
className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()}
|
||||||
onClick={() => openReader(ch, sortedChapters)}
|
onClick={() => openReader(ch, sortedChapters)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)}
|
||||||
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
|
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
|
||||||
>
|
>
|
||||||
<div className={s.chLeft}>
|
<div className={s.chLeft}>
|
||||||
@@ -676,19 +1086,30 @@ export default function SeriesDetail() {
|
|||||||
{ch.isBookmarked && (
|
{ch.isBookmarked && (
|
||||||
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
|
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
|
||||||
)}
|
)}
|
||||||
{ch.isRead ? (
|
{ch.isRead && (
|
||||||
<CheckCircle size={14} weight="light" className={s.readIcon} />
|
<CheckCircle size={14} weight="light" className={s.readIcon} />
|
||||||
) : ch.isDownloaded ? (
|
)}
|
||||||
<BookOpen size={14} weight="light" className={s.downloadedIcon} />
|
{ch.isDownloaded ? (
|
||||||
|
<button
|
||||||
|
className={s.dlBtn}
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}
|
||||||
|
title="Delete download"
|
||||||
|
>
|
||||||
|
<Trash size={13} weight="light" />
|
||||||
|
</button>
|
||||||
) : enqueueing.has(ch.id) ? (
|
) : enqueueing.has(ch.id) ? (
|
||||||
<CircleNotch size={14} weight="light" className={[s.enqueuingIcon, "anim-spin"].join(" ")} />
|
<CircleNotch size={14} weight="light" className={[s.enqueuingIcon, "anim-spin"].join(" ")} />
|
||||||
) : (
|
) : (
|
||||||
<button className={s.dlBtn} onClick={(e) => enqueue(ch, e)} title="Download">
|
<button
|
||||||
|
className={s.dlBtn}
|
||||||
|
onClick={(e) => enqueue(ch, e)}
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
<Download size={13} weight="light" />
|
<Download size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -459,3 +459,103 @@
|
|||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
color: var(--accent-fg);
|
color: var(--accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Theme picker ── */
|
||||||
|
.themeGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeCard {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.themeCard:hover { border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||||
|
.themeCardActive {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
.themeCardActive:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.themePreview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(0,0,0,0.15);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themePreviewBg {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themePreviewSidebar {
|
||||||
|
width: 22%;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themePreviewContent {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10% 12%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themePreviewAccent {
|
||||||
|
height: 14%;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themePreviewText {
|
||||||
|
height: 9%;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeCardInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeCardLabel {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeCardDesc {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeCardCheck {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--sp-1);
|
||||||
|
right: var(--sp-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash } from "@phosphor-icons/react";
|
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "@phosphor-icons/react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { gql } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Folder } from "../../store";
|
import type { Folder } from "../../store";
|
||||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
||||||
import type { Settings, FitMode } from "../../store";
|
import type { Settings, FitMode, Theme } from "../../store";
|
||||||
import s from "./Settings.module.css";
|
import s from "./Settings.module.css";
|
||||||
|
|
||||||
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about";
|
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
|
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
|
||||||
|
{ id: "appearance", label: "Appearance", icon: <PaintBrush size={14} weight="light" /> },
|
||||||
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
|
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
|
||||||
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
|
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
|
||||||
{ id: "performance",label: "Performance",icon: <Sliders size={14} weight="light" /> },
|
{ id: "performance",label: "Performance",icon: <Sliders size={14} weight="light" /> },
|
||||||
@@ -20,6 +21,7 @@ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|||||||
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
|
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
|
||||||
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
|
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
|
||||||
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
||||||
|
{ id: "devtools", label: "Dev Tools", icon: <Wrench size={14} weight="light" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Primitives ────────────────────────────────────────────────────────────────
|
// ── Primitives ────────────────────────────────────────────────────────────────
|
||||||
@@ -134,6 +136,7 @@ function TextRow({ value, onChange, label, description, placeholder }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||||
@@ -174,6 +177,24 @@ function GeneralTab({ settings, update }: { settings: Settings; update: (p: Part
|
|||||||
checked={settings.autoStartServer}
|
checked={settings.autoStartServer}
|
||||||
onChange={(v) => update({ autoStartServer: v })} />
|
onChange={(v) => update({ autoStartServer: v })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Inactivity</p>
|
||||||
|
<SelectRow
|
||||||
|
label="Idle screen timeout"
|
||||||
|
description="Show the Moku idle splash after this much inactivity. Set to Never to disable."
|
||||||
|
value={String(settings.idleTimeoutMin ?? 5)}
|
||||||
|
options={[
|
||||||
|
{ value: "0", label: "Never" },
|
||||||
|
{ value: "1", label: "1 minute" },
|
||||||
|
{ value: "2", label: "2 minutes" },
|
||||||
|
{ value: "5", label: "5 minutes" },
|
||||||
|
{ value: "10", label: "10 minutes" },
|
||||||
|
{ value: "15", label: "15 minutes" },
|
||||||
|
{ value: "30", label: "30 minutes" },
|
||||||
|
]}
|
||||||
|
onChange={(v) => update({ idleTimeoutMin: Number(v) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -340,6 +361,13 @@ function PerformanceTab({ settings, update }: { settings: Settings; update: (p:
|
|||||||
checked={settings.gpuAcceleration}
|
checked={settings.gpuAcceleration}
|
||||||
onChange={(v) => update({ gpuAcceleration: v })} />
|
onChange={(v) => update({ gpuAcceleration: v })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Idle / Splash Screen</p>
|
||||||
|
<Toggle label="Animated card background"
|
||||||
|
description="Show floating manga cards on the splash and idle screens. Disable if the animation feels slow on your machine."
|
||||||
|
checked={settings.splashCards ?? true}
|
||||||
|
onChange={(v) => update({ splashCards: v })} />
|
||||||
|
</div>
|
||||||
<div className={s.section}>
|
<div className={s.section}>
|
||||||
<p className={s.sectionTitle}>Interface</p>
|
<p className={s.sectionTitle}>Interface</p>
|
||||||
<Toggle label="Compact sidebar"
|
<Toggle label="Compact sidebar"
|
||||||
@@ -347,6 +375,18 @@ function PerformanceTab({ settings, update }: { settings: Settings; update: (p:
|
|||||||
checked={settings.compactSidebar}
|
checked={settings.compactSidebar}
|
||||||
onChange={(v) => update({ compactSidebar: v })} />
|
onChange={(v) => update({ compactSidebar: v })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Reader</p>
|
||||||
|
<Stepper
|
||||||
|
label="Input debounce"
|
||||||
|
description="Delay (ms) before page-turn input is processed. Increase if the reader feels laggy or skips pages. Set to 0 to disable."
|
||||||
|
value={settings.readerDebounceMs ?? 120}
|
||||||
|
min={0}
|
||||||
|
max={500}
|
||||||
|
step={20}
|
||||||
|
onChange={(v) => update({ readerDebounceMs: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -690,6 +730,133 @@ function FoldersTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Appearance tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const THEMES: { id: Theme; label: string; description: string; swatches: string[] }[] = [
|
||||||
|
{
|
||||||
|
id: "dark",
|
||||||
|
label: "Dark",
|
||||||
|
description: "Default near-black",
|
||||||
|
swatches: ["#101010", "#151515", "#a8c4a8", "#f0efec"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "high-contrast",
|
||||||
|
label: "High Contrast",
|
||||||
|
description: "Darker base, sharper text",
|
||||||
|
swatches: ["#080808", "#111111", "#bcd8bc", "#ffffff"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "light",
|
||||||
|
label: "Light",
|
||||||
|
description: "Warm off-white",
|
||||||
|
swatches: ["#f4f2ee", "#faf8f4", "#2a5a2a", "#1a1916"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "light-contrast",
|
||||||
|
label: "Light Contrast",
|
||||||
|
description: "Light with maximum text contrast",
|
||||||
|
swatches: ["#ece8e2", "#f5f2ec", "#183818", "#080806"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "midnight",
|
||||||
|
label: "Midnight",
|
||||||
|
description: "Deep blue-black tint",
|
||||||
|
swatches: ["#0c1020", "#101428", "#a8b4e8", "#eeeef8"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "warm",
|
||||||
|
label: "Warm",
|
||||||
|
description: "Amber and sepia tones",
|
||||||
|
swatches: ["#16130c", "#1c1810", "#e0b860", "#f5f0e0"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function AppearanceTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||||
|
const current = settings.theme ?? "dark";
|
||||||
|
return (
|
||||||
|
<div className={s.panel}>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Theme</p>
|
||||||
|
<div className={s.themeGrid}>
|
||||||
|
{THEMES.map((theme) => {
|
||||||
|
const active = current === theme.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={theme.id}
|
||||||
|
className={[s.themeCard, active ? s.themeCardActive : ""].join(" ")}
|
||||||
|
onClick={() => update({ theme: theme.id })}
|
||||||
|
title={theme.description}
|
||||||
|
>
|
||||||
|
<div className={s.themePreview}>
|
||||||
|
{/* Mini UI preview using the theme swatches */}
|
||||||
|
<div className={s.themePreviewBg} style={{ background: theme.swatches[0] }}>
|
||||||
|
<div className={s.themePreviewSidebar} style={{ background: theme.swatches[1] }} />
|
||||||
|
<div className={s.themePreviewContent}>
|
||||||
|
<div className={s.themePreviewAccent} style={{ background: theme.swatches[2] }} />
|
||||||
|
<div className={s.themePreviewText} style={{ background: theme.swatches[3] + "55" }} />
|
||||||
|
<div className={s.themePreviewText} style={{ background: theme.swatches[3] + "33", width: "60%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={s.themeCardInfo}>
|
||||||
|
<span className={s.themeCardLabel}>{theme.label}</span>
|
||||||
|
<span className={s.themeCardDesc}>{theme.description}</span>
|
||||||
|
</div>
|
||||||
|
{active && <span className={s.themeCardCheck}>✓</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevToolsTab() {
|
||||||
|
const [splashTriggered, setSplashTriggered] = useState(false);
|
||||||
|
|
||||||
|
function triggerSplash() {
|
||||||
|
setSplashTriggered(true);
|
||||||
|
setTimeout(() => setSplashTriggered(false), 200);
|
||||||
|
(window as any).__mokuShowSplash?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.panel}>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Splash Screen</p>
|
||||||
|
<div className={s.stepRow}>
|
||||||
|
<div className={s.toggleInfo}>
|
||||||
|
<span className={s.toggleLabel}>Preview idle screen</span>
|
||||||
|
<span className={s.toggleDesc}>Show the idle splash — dismiss with any click or key</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={s.dangerBtn}
|
||||||
|
style={{ background: splashTriggered ? "var(--accent-fg)" : undefined,
|
||||||
|
color: splashTriggered ? "var(--bg-base)" : undefined,
|
||||||
|
borderColor: splashTriggered ? "var(--accent-fg)" : undefined,
|
||||||
|
transition: "all 0.15s ease" }}
|
||||||
|
onClick={triggerSplash}
|
||||||
|
>
|
||||||
|
Show idle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Build Info</p>
|
||||||
|
<div className={s.aboutBlock}>
|
||||||
|
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)" }}>
|
||||||
|
Mode: {import.meta.env.MODE}
|
||||||
|
</p>
|
||||||
|
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)", marginTop: "var(--sp-1)" }}>
|
||||||
|
Dev: {String(import.meta.env.DEV)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AboutTab() {
|
function AboutTab() {
|
||||||
return (
|
return (
|
||||||
<div className={s.panel}>
|
<div className={s.panel}>
|
||||||
@@ -717,6 +884,8 @@ export default function SettingsModal() {
|
|||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
const contentBodyRef = useRef<HTMLDivElement>(null);
|
const contentBodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
contentBodyRef.current?.scrollTo({ top: 0 });
|
contentBodyRef.current?.scrollTo({ top: 0 });
|
||||||
}, [tab]);
|
}, [tab]);
|
||||||
@@ -755,6 +924,7 @@ export default function SettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={s.contentBody} ref={contentBodyRef}>
|
<div className={s.contentBody} ref={contentBodyRef}>
|
||||||
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
|
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
|
||||||
|
{tab === "appearance" && <AppearanceTab settings={settings} update={updateSettings} />}
|
||||||
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
|
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
|
||||||
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
|
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
|
||||||
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
|
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
|
||||||
@@ -762,6 +932,7 @@ export default function SettingsModal() {
|
|||||||
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
|
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
|
||||||
{tab === "folders" && <FoldersTab />}
|
{tab === "folders" && <FoldersTab />}
|
||||||
{tab === "about" && <AboutTab />}
|
{tab === "about" && <AboutTab />}
|
||||||
|
{tab === "devtools" && <DevToolsTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next } from "@phosphor-icons/react";
|
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||||
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga } from "../../lib/types";
|
import type { Manga } from "../../lib/types";
|
||||||
import s from "./SourceBrowse.module.css";
|
import s from "./SourceBrowse.module.css";
|
||||||
@@ -13,6 +14,10 @@ export default function SourceBrowse() {
|
|||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const setNavPage = useStore((state) => state.setNavPage);
|
const setNavPage = useStore((state) => state.setNavPage);
|
||||||
|
const folders = useStore((state) => state.settings.folders);
|
||||||
|
const addFolder = useStore((state) => state.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
||||||
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
|
||||||
const [mangas, setMangas] = useState<Manga[]>([]);
|
const [mangas, setMangas] = useState<Manga[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -63,6 +68,45 @@ export default function SourceBrowse() {
|
|||||||
setNavPage("library");
|
setNavPage("library");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => setMangas((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeSource) return null;
|
if (!activeSource) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,7 +164,7 @@ export default function SourceBrowse() {
|
|||||||
) : (
|
) : (
|
||||||
<div className={s.grid}>
|
<div className={s.grid}>
|
||||||
{mangas.map((m) => (
|
{mangas.map((m) => (
|
||||||
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
|
<button key={m.id} className={s.card} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||||
<div className={s.coverWrap}>
|
<div className={s.coverWrap}>
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||||
{m.inLibrary && <span className={s.inLibraryBadge}>In Library</span>}
|
{m.inLibrary && <span className={s.inLibraryBadge}>In Library</span>}
|
||||||
@@ -152,6 +196,14 @@ export default function SourceBrowse() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctx.x}
|
||||||
|
y={ctx.y}
|
||||||
|
items={buildCtxItems(ctx.manga)}
|
||||||
|
onClose={() => setCtx(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Session-level request cache.
|
||||||
|
*
|
||||||
|
* Key design decisions:
|
||||||
|
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
|
||||||
|
* - On real errors the entry is evicted so the next call retries.
|
||||||
|
* - AbortErrors do NOT evict — the request was cancelled by the user, not failed.
|
||||||
|
* This is critical: if we evicted on abort, rapid open/close would drain the browser's
|
||||||
|
* connection pool (Chromium allows only 6 concurrent connections to the same origin).
|
||||||
|
* - Subscribers are notified when a key is explicitly cleared (for reactive invalidation).
|
||||||
|
*/
|
||||||
|
const store = new Map<string, Promise<unknown>>();
|
||||||
|
const subs = new Map<string, Set<() => void>>();
|
||||||
|
|
||||||
|
export const cache = {
|
||||||
|
get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
if (!store.has(key)) {
|
||||||
|
store.set(key, fetcher().catch((err) => {
|
||||||
|
// Only evict on real failures, not user cancellations
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return store.get(key) as Promise<T>;
|
||||||
|
},
|
||||||
|
has(key: string): boolean { return store.has(key); },
|
||||||
|
clear(key: string) {
|
||||||
|
store.delete(key);
|
||||||
|
subs.get(key)?.forEach((cb) => cb());
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
store.clear();
|
||||||
|
subs.forEach((set) => set.forEach((cb) => cb()));
|
||||||
|
},
|
||||||
|
/** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */
|
||||||
|
subscribe(key: string, cb: () => void): () => void {
|
||||||
|
if (!subs.has(key)) subs.set(key, new Set());
|
||||||
|
subs.get(key)!.add(cb);
|
||||||
|
return () => subs.get(key)?.delete(cb);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Cache key constants — single source of truth, prevents mismatches ─────────
|
||||||
|
export const CACHE_KEYS = {
|
||||||
|
LIBRARY: "library",
|
||||||
|
SOURCES: "sources",
|
||||||
|
POPULAR: "popular",
|
||||||
|
GENRE: (genre: string) => `genre:${genre}`,
|
||||||
|
MANGA: (id: number) => `manga:${id}`,
|
||||||
|
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ── In-flight request deduplication (for non-cached calls) ────────────────────
|
||||||
|
//
|
||||||
|
// Some requests (chapter lists, manga detail) are NOT stored in the long-lived
|
||||||
|
// cache but still get fired multiple times when a user rapidly opens/closes a
|
||||||
|
// manga. This map deduplicates them so only one network round-trip is active at
|
||||||
|
// a time per key — regardless of how many components request it simultaneously.
|
||||||
|
//
|
||||||
|
const inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
|
||||||
|
const p = fetcher().finally(() => inflight.delete(key));
|
||||||
|
inflight.set(key, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source frecency helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FRECENCY_KEY = "moku-source-frecency";
|
||||||
|
const MAX_FRECENCY_SOURCES = 4;
|
||||||
|
|
||||||
|
type FrecencyMap = Record<string, number>;
|
||||||
|
|
||||||
|
function loadFrecency(): FrecencyMap {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FRECENCY_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFrecency(map: FrecencyMap) {
|
||||||
|
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordSourceAccess(sourceId: string) {
|
||||||
|
if (!sourceId || sourceId === "0") return;
|
||||||
|
const map = loadFrecency();
|
||||||
|
map[sourceId] = (map[sourceId] ?? 0) + 1;
|
||||||
|
saveFrecency(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
||||||
|
const map = loadFrecency();
|
||||||
|
const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 }));
|
||||||
|
const hasFrecency = withScore.some((x) => x.score > 0);
|
||||||
|
|
||||||
|
if (hasFrecency) {
|
||||||
|
return withScore
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, MAX_FRECENCY_SOURCES)
|
||||||
|
.map((x) => x.s);
|
||||||
|
}
|
||||||
|
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
||||||
|
}
|
||||||
+53
-14
@@ -1,7 +1,6 @@
|
|||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
function getServerUrl(): string {
|
function getServerUrl(): string {
|
||||||
// Read from persisted Zustand store if available, fall back to default
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem("moku-store");
|
const raw = localStorage.getItem("moku-store");
|
||||||
if (raw) {
|
if (raw) {
|
||||||
@@ -26,15 +25,55 @@ interface GQLResponse<T> {
|
|||||||
errors?: { message: string }[];
|
errors?: { message: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry with exponential backoff — Suwayomi may not be ready on first load
|
/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */
|
||||||
async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delayMs = 500): Promise<Response> {
|
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||||
|
const timer = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry wrapper with these guarantees:
|
||||||
|
* 1. AbortErrors always propagate immediately — no retry, no delay.
|
||||||
|
* 2. Retry delays are abort-aware — closing a manga mid-delay doesn't hang.
|
||||||
|
* 3. If the signal is already aborted before we even start, we bail instantly.
|
||||||
|
*/
|
||||||
|
async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
retries = 3,
|
||||||
|
delayMs = 300,
|
||||||
|
): Promise<Response> {
|
||||||
|
// Bail immediately if already aborted before we start
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
|
// Check abort at the top of every iteration
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, init);
|
const res = await fetch(url, { ...init, signal });
|
||||||
|
|
||||||
|
// Check abort again — fetch can return a response even after abort in some runtimes
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
|
// Never retry aborted requests
|
||||||
|
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||||
|
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
|
// Last retry — give up
|
||||||
if (i === retries - 1) throw e;
|
if (i === retries - 1) throw e;
|
||||||
await new Promise((r) => setTimeout(r, delayMs * Math.pow(1.5, i)));
|
|
||||||
|
// Abort-aware delay between retries
|
||||||
|
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error("unreachable");
|
throw new Error("unreachable");
|
||||||
@@ -42,23 +81,23 @@ async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delay
|
|||||||
|
|
||||||
export async function gql<T>(
|
export async function gql<T>(
|
||||||
query: string,
|
query: string,
|
||||||
variables?: Record<string, unknown>
|
variables?: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchWithRetry(gqlUrl(), {
|
const res = await fetchWithRetry(gqlUrl(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
});
|
}, signal);
|
||||||
|
|
||||||
if (!res.ok) {
|
// Check abort before reading the body — avoids hanging on res.json() after cancel
|
||||||
throw new Error(`Suwayomi HTTP ${res.status}`);
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
}
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
|
||||||
const json: GQLResponse<T> = await res.json();
|
const json: GQLResponse<T> = await res.json();
|
||||||
|
|
||||||
if (json.errors?.length) {
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
throw new Error(json.errors[0].message);
|
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||||
}
|
|
||||||
|
|
||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
+32
-5
@@ -10,6 +10,7 @@ export const GET_LIBRARY = `
|
|||||||
inLibrary
|
inLibrary
|
||||||
downloadCount
|
downloadCount
|
||||||
unreadCount
|
unreadCount
|
||||||
|
genre
|
||||||
chapters {
|
chapters {
|
||||||
totalCount
|
totalCount
|
||||||
}
|
}
|
||||||
@@ -249,23 +250,49 @@ export const DEQUEUE_DOWNLOAD = `
|
|||||||
|
|
||||||
export const START_DOWNLOADER = `
|
export const START_DOWNLOADER = `
|
||||||
mutation StartDownloader {
|
mutation StartDownloader {
|
||||||
startDownloader {
|
startDownloader(input: {}) {
|
||||||
downloadStatus { state }
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress
|
||||||
|
state
|
||||||
|
chapter {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
pageCount
|
||||||
|
mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const STOP_DOWNLOADER = `
|
export const STOP_DOWNLOADER = `
|
||||||
mutation StopDownloader {
|
mutation StopDownloader {
|
||||||
stopDownloader {
|
stopDownloader(input: {}) {
|
||||||
downloadStatus { state }
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress
|
||||||
|
state
|
||||||
|
chapter {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
pageCount
|
||||||
|
mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const CLEAR_DOWNLOADER = `
|
export const CLEAR_DOWNLOADER = `
|
||||||
mutation ClearDownloader {
|
mutation ClearDownloader {
|
||||||
clearDownloader {
|
clearDownloader(input: {}) {
|
||||||
downloadStatus {
|
downloadStatus {
|
||||||
state
|
state
|
||||||
queue {
|
queue {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Source } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates sources by name, preferring the given language.
|
||||||
|
* This prevents fetching MangaDex EN + MangaDex ES + MangaDex FR separately.
|
||||||
|
*/
|
||||||
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
|
const byName = new Map<string, Source[]>();
|
||||||
|
for (const src of sources) {
|
||||||
|
if (src.id === "0") continue;
|
||||||
|
if (!byName.has(src.name)) byName.set(src.name, []);
|
||||||
|
byName.get(src.name)!.push(src);
|
||||||
|
}
|
||||||
|
const picked: Source[] = [];
|
||||||
|
for (const group of byName.values()) {
|
||||||
|
const preferred = group.find((s) => s.lang === preferredLang);
|
||||||
|
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates manga by title (case-insensitive), keeping the first occurrence.
|
||||||
|
* This eliminates the same series appearing from multiple sources in grids.
|
||||||
|
*/
|
||||||
|
export function dedupeMangaByTitle<T extends { id: number; title: string }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const m of items) {
|
||||||
|
const key = m.title.toLowerCase().trim();
|
||||||
|
if (!seen.has(key)) { seen.add(key); out.push(m); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates manga by id only (lossless — use when sources are already deduped).
|
||||||
|
*/
|
||||||
|
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const m of items) {
|
||||||
|
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+55
-2
@@ -6,9 +6,16 @@ import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
|||||||
export type PageStyle = "single" | "double" | "longstrip";
|
export type PageStyle = "single" | "double" | "longstrip";
|
||||||
export type FitMode = "width" | "height" | "screen" | "original";
|
export type FitMode = "width" | "height" | "screen" | "original";
|
||||||
export type LibraryFilter = "all" | "library" | "downloaded" | string; // string = folder id
|
export type LibraryFilter = "all" | "library" | "downloaded" | string; // string = folder id
|
||||||
export type NavPage = "library" | "sources" | "downloads" | "extensions" | "history" | "search";
|
export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
||||||
export type ReadingDirection = "ltr" | "rtl";
|
export type ReadingDirection = "ltr" | "rtl";
|
||||||
export type ChapterSortDir = "desc" | "asc";
|
export type ChapterSortDir = "desc" | "asc";
|
||||||
|
export type Theme =
|
||||||
|
| "dark" // default — near-black
|
||||||
|
| "high-contrast" // darker + sharper text
|
||||||
|
| "light" // warm off-white
|
||||||
|
| "light-contrast" // light + max contrast
|
||||||
|
| "midnight" // blue-black tint
|
||||||
|
| "warm"; // amber/sepia tint
|
||||||
|
|
||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
@@ -20,6 +27,14 @@ export interface HistoryEntry {
|
|||||||
readAt: number;
|
readAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
kind: "success" | "error" | "info" | "download";
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActiveDownload {
|
export interface ActiveDownload {
|
||||||
chapterId: number;
|
chapterId: number;
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
@@ -57,12 +72,18 @@ export interface Settings {
|
|||||||
autoStartServer: boolean;
|
autoStartServer: boolean;
|
||||||
preferredExtensionLang: string;
|
preferredExtensionLang: string;
|
||||||
keybinds: Keybinds;
|
keybinds: Keybinds;
|
||||||
|
idleTimeoutMin?: number;
|
||||||
|
splashCards?: boolean;
|
||||||
storageLimitGb: number | null;
|
storageLimitGb: number | null;
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
|
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
|
||||||
|
readerDebounceMs: number;
|
||||||
|
/** UI colour theme. Applied as data-theme on <html>. */
|
||||||
|
theme: Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
pageStyle: "single",
|
pageStyle: "longstrip",
|
||||||
readingDirection: "ltr",
|
readingDirection: "ltr",
|
||||||
fitMode: "width",
|
fitMode: "width",
|
||||||
maxPageWidth: 900,
|
maxPageWidth: 900,
|
||||||
@@ -85,15 +106,25 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
autoStartServer: true,
|
autoStartServer: true,
|
||||||
preferredExtensionLang: "en",
|
preferredExtensionLang: "en",
|
||||||
keybinds: DEFAULT_KEYBINDS,
|
keybinds: DEFAULT_KEYBINDS,
|
||||||
|
idleTimeoutMin: 5,
|
||||||
|
splashCards: true,
|
||||||
storageLimitGb: null,
|
storageLimitGb: null,
|
||||||
folders: [],
|
folders: [],
|
||||||
|
readerDebounceMs: 120,
|
||||||
|
theme: "dark",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Store {
|
interface Store {
|
||||||
navPage: NavPage;
|
navPage: NavPage;
|
||||||
setNavPage: (page: NavPage) => void;
|
setNavPage: (page: NavPage) => void;
|
||||||
|
genreFilter: string;
|
||||||
|
setGenreFilter: (genre: string) => void;
|
||||||
|
searchPrefill: string;
|
||||||
|
setSearchPrefill: (q: string) => void;
|
||||||
activeManga: Manga | null;
|
activeManga: Manga | null;
|
||||||
setActiveManga: (manga: Manga | null) => void;
|
setActiveManga: (manga: Manga | null) => void;
|
||||||
|
previewManga: Manga | null;
|
||||||
|
setPreviewManga: (manga: Manga | null) => void;
|
||||||
activeChapter: Chapter | null;
|
activeChapter: Chapter | null;
|
||||||
activeChapterList: Chapter[];
|
activeChapterList: Chapter[];
|
||||||
openReader: (chapter: Chapter, chapterList: Chapter[]) => void;
|
openReader: (chapter: Chapter, chapterList: Chapter[]) => void;
|
||||||
@@ -116,6 +147,9 @@ interface Store {
|
|||||||
history: HistoryEntry[];
|
history: HistoryEntry[];
|
||||||
addHistory: (entry: HistoryEntry) => void;
|
addHistory: (entry: HistoryEntry) => void;
|
||||||
clearHistory: () => void;
|
clearHistory: () => void;
|
||||||
|
toasts: Toast[];
|
||||||
|
addToast: (toast: Omit<Toast, "id">) => void;
|
||||||
|
dismissToast: (id: string) => void;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
updateSettings: (patch: Partial<Settings>) => void;
|
updateSettings: (patch: Partial<Settings>) => void;
|
||||||
resetKeybinds: () => void;
|
resetKeybinds: () => void;
|
||||||
@@ -138,8 +172,14 @@ export const useStore = create<Store>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
navPage: "library",
|
navPage: "library",
|
||||||
setNavPage: (navPage) => set({ navPage }),
|
setNavPage: (navPage) => set({ navPage }),
|
||||||
|
genreFilter: "",
|
||||||
|
setGenreFilter: (genreFilter) => set({ genreFilter }),
|
||||||
|
searchPrefill: "",
|
||||||
|
setSearchPrefill: (searchPrefill) => set({ searchPrefill }),
|
||||||
activeManga: null,
|
activeManga: null,
|
||||||
setActiveManga: (activeManga) => set({ activeManga }),
|
setActiveManga: (activeManga) => set({ activeManga }),
|
||||||
|
previewManga: null,
|
||||||
|
setPreviewManga: (previewManga) => set({ previewManga }),
|
||||||
activeChapter: null,
|
activeChapter: null,
|
||||||
activeChapterList: [],
|
activeChapterList: [],
|
||||||
openReader: (chapter, chapterList) =>
|
openReader: (chapter, chapterList) =>
|
||||||
@@ -164,10 +204,23 @@ export const useStore = create<Store>()(
|
|||||||
history: [],
|
history: [],
|
||||||
addHistory: (entry) =>
|
addHistory: (entry) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
|
const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId);
|
||||||
|
if (existing === 0) {
|
||||||
|
const updated = [...s.history];
|
||||||
|
updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||||
|
return { history: updated };
|
||||||
|
}
|
||||||
const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId);
|
const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId);
|
||||||
return { history: [entry, ...deduped].slice(0, 300) };
|
return { history: [entry, ...deduped].slice(0, 300) };
|
||||||
}),
|
}),
|
||||||
clearHistory: () => set({ history: [] }),
|
clearHistory: () => set({ history: [] }),
|
||||||
|
toasts: [],
|
||||||
|
addToast: (toast) =>
|
||||||
|
set((s) => ({
|
||||||
|
toasts: [...s.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5),
|
||||||
|
})),
|
||||||
|
dismissToast: (id) =>
|
||||||
|
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||||
settings: DEFAULT_SETTINGS,
|
settings: DEFAULT_SETTINGS,
|
||||||
updateSettings: (patch) =>
|
updateSettings: (patch) =>
|
||||||
set((s) => ({ settings: { ...s.settings, ...patch } })),
|
set((s) => ({ settings: { ...s.settings, ...patch } })),
|
||||||
|
|||||||
@@ -107,3 +107,156 @@
|
|||||||
--t-base: 0.14s ease;
|
--t-base: 0.14s ease;
|
||||||
--t-slow: 0.22s ease;
|
--t-slow: 0.22s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
Themes
|
||||||
|
Applied via data-theme on <html>.
|
||||||
|
"dark" = default (no overrides needed, inherits :root).
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* ── High Contrast (dark base, sharper text) ── */
|
||||||
|
[data-theme="high-contrast"] {
|
||||||
|
--bg-void: #000000;
|
||||||
|
--bg-base: #080808;
|
||||||
|
--bg-surface: #0d0d0d;
|
||||||
|
--bg-raised: #111111;
|
||||||
|
--bg-overlay: #171717;
|
||||||
|
--bg-subtle: #1e1e1e;
|
||||||
|
|
||||||
|
--border-dim: #252525;
|
||||||
|
--border-base: #303030;
|
||||||
|
--border-strong: #3e3e3e;
|
||||||
|
--border-focus: #5a7a5a;
|
||||||
|
|
||||||
|
/* Text bumped up significantly for contrast */
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #e8e6e0;
|
||||||
|
--text-muted: #b0aea8;
|
||||||
|
--text-faint: #6e6c68;
|
||||||
|
--text-disabled: #303030;
|
||||||
|
|
||||||
|
--accent: #7aaa7a;
|
||||||
|
--accent-dim: #2e4a2e;
|
||||||
|
--accent-muted: #1e2e1e;
|
||||||
|
--accent-fg: #bcd8bc;
|
||||||
|
--accent-bright: #9fcf9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light mode ── */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-void: #e8e6e2;
|
||||||
|
--bg-base: #eeece8;
|
||||||
|
--bg-surface: #f4f2ee;
|
||||||
|
--bg-raised: #faf8f4;
|
||||||
|
--bg-overlay: #ffffff;
|
||||||
|
--bg-subtle: #f0ede8;
|
||||||
|
|
||||||
|
--border-dim: #dedad4;
|
||||||
|
--border-base: #d0ccc6;
|
||||||
|
--border-strong: #bbb6ae;
|
||||||
|
--border-focus: #5a7a5a;
|
||||||
|
|
||||||
|
--text-primary: #1a1916;
|
||||||
|
--text-secondary: #2e2c28;
|
||||||
|
--text-muted: #5a5750;
|
||||||
|
--text-faint: #9a9890;
|
||||||
|
--text-disabled: #c8c4bc;
|
||||||
|
|
||||||
|
--accent: #4a724a;
|
||||||
|
--accent-dim: #c8dcc8;
|
||||||
|
--accent-muted: #deeade;
|
||||||
|
--accent-fg: #2a5a2a;
|
||||||
|
--accent-bright: #3a6a3a;
|
||||||
|
|
||||||
|
--color-error: #a03030;
|
||||||
|
--color-error-bg: #fce8e8;
|
||||||
|
--color-success: #2a6a2a;
|
||||||
|
--color-info: #2a4a7a;
|
||||||
|
--color-info-bg: #e8eef8;
|
||||||
|
--color-read: #e8e4dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light High Contrast ── */
|
||||||
|
[data-theme="light-contrast"] {
|
||||||
|
--bg-void: #d8d4ce;
|
||||||
|
--bg-base: #e2deda;
|
||||||
|
--bg-surface: #ece8e2;
|
||||||
|
--bg-raised: #f5f2ec;
|
||||||
|
--bg-overlay: #ffffff;
|
||||||
|
--bg-subtle: #e4e0d8;
|
||||||
|
|
||||||
|
--border-dim: #c4c0b8;
|
||||||
|
--border-base: #b0aca4;
|
||||||
|
--border-strong: #989490;
|
||||||
|
--border-focus: #3a5a3a;
|
||||||
|
|
||||||
|
--text-primary: #080806;
|
||||||
|
--text-secondary: #181612;
|
||||||
|
--text-muted: #38342e;
|
||||||
|
--text-faint: #706c64;
|
||||||
|
--text-disabled: #b0ac a4;
|
||||||
|
|
||||||
|
--accent: #2a5a2a;
|
||||||
|
--accent-dim: #b0ccb0;
|
||||||
|
--accent-muted: #c8dcc8;
|
||||||
|
--accent-fg: #183818;
|
||||||
|
--accent-bright: #1e4e1e;
|
||||||
|
|
||||||
|
--color-error: #8a1a1a;
|
||||||
|
--color-error-bg: #f8e0e0;
|
||||||
|
--color-read: #e0dcd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Midnight (deep blue-black tint) ── */
|
||||||
|
[data-theme="midnight"] {
|
||||||
|
--bg-void: #050810;
|
||||||
|
--bg-base: #080c18;
|
||||||
|
--bg-surface: #0c1020;
|
||||||
|
--bg-raised: #101428;
|
||||||
|
--bg-overlay: #151a30;
|
||||||
|
--bg-subtle: #1a2038;
|
||||||
|
|
||||||
|
--border-dim: #1a2035;
|
||||||
|
--border-base: #222840;
|
||||||
|
--border-strong: #2c3450;
|
||||||
|
--border-focus: #4a5c8a;
|
||||||
|
|
||||||
|
--text-primary: #eeeef8;
|
||||||
|
--text-secondary: #c0c4d8;
|
||||||
|
--text-muted: #808498;
|
||||||
|
--text-faint: #404860;
|
||||||
|
--text-disabled: #202840;
|
||||||
|
|
||||||
|
--accent: #6a7ab8;
|
||||||
|
--accent-dim: #252d50;
|
||||||
|
--accent-muted: #181e38;
|
||||||
|
--accent-fg: #a8b4e8;
|
||||||
|
--accent-bright: #8896d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Warm (sepia / amber tinted) ── */
|
||||||
|
[data-theme="warm"] {
|
||||||
|
--bg-void: #0c0a06;
|
||||||
|
--bg-base: #100e08;
|
||||||
|
--bg-surface: #16130c;
|
||||||
|
--bg-raised: #1c1810;
|
||||||
|
--bg-overlay: #221e14;
|
||||||
|
--bg-subtle: #28241a;
|
||||||
|
|
||||||
|
--border-dim: #201c10;
|
||||||
|
--border-base: #2c2818;
|
||||||
|
--border-strong: #3a3420;
|
||||||
|
--border-focus: #6a5a30;
|
||||||
|
|
||||||
|
--text-primary: #f5f0e0;
|
||||||
|
--text-secondary: #d8d0b0;
|
||||||
|
--text-muted: #988c60;
|
||||||
|
--text-faint: #584e30;
|
||||||
|
--text-disabled: #302a18;
|
||||||
|
|
||||||
|
--accent: #c0902a;
|
||||||
|
--accent-dim: #3a2c10;
|
||||||
|
--accent-muted: #261e0c;
|
||||||
|
--accent-fg: #e0b860;
|
||||||
|
--accent-bright: #d0a040;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user