mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55d1431673 | |||
| 11247a69fe | |||
| dc6db4dd98 | |||
| 5c586f39a2 | |||
| f21110dbdb | |||
| dfabb82237 |
@@ -0,0 +1,127 @@
|
||||
pkgname=moku
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/shozikan/Moku"
|
||||
license=('MIT')
|
||||
depends=(
|
||||
'webkit2gtk-4.1'
|
||||
'gtk3'
|
||||
'libayatana-appindicator'
|
||||
'java-runtime>=21'
|
||||
)
|
||||
makedepends=(
|
||||
'rust'
|
||||
'cargo'
|
||||
'nodejs'
|
||||
'pnpm'
|
||||
)
|
||||
source=(
|
||||
"$pkgname-$pkgver.tar.gz::https://github.com/shozikan/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
||||
)
|
||||
sha256sums=(
|
||||
'0019dfc4b32d63c1392aa264aed2253c1e0c2fb09216f8e2cc269bbfb8bb49b5'
|
||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d'
|
||||
)
|
||||
|
||||
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"
|
||||
}
|
||||
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: .
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: 58b475fccdd41807dfd5e55e236925b9d88ed1df49367fb93f7e463d7ae204d2
|
||||
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
version = "0.1.0";
|
||||
src = frontendSrc;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-2Hdzsjwbb+CKiRn/nGHwLeysKvpvEhd5C213YgWmOSU=";
|
||||
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Binary file not shown.
Generated
+20
@@ -23,6 +23,9 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
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)
|
||||
'@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':
|
||||
specifier: ^2.0.0
|
||||
version: 2.10.1
|
||||
@@ -837,6 +840,15 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
|
||||
@@ -2107,6 +2119,14 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.58.0':
|
||||
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/cli-darwin-arm64@2.10.0':
|
||||
|
||||
Generated
+1
-1
@@ -1797,7 +1797,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moku"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"nix",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "dev.moku.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-1);
|
||||
min-width: 180px;
|
||||
min-width: 190px;
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.5),
|
||||
0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
0 0 0 1px rgba(0,0,0,0.08),
|
||||
0 4px 12px rgba(0,0,0,0.35),
|
||||
0 16px 40px rgba(0,0,0,0.25);
|
||||
animation: scaleIn 0.1s ease both;
|
||||
transform-origin: top left;
|
||||
}
|
||||
@@ -18,7 +19,7 @@
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: 6px var(--sp-3);
|
||||
padding: 5px var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
@@ -27,29 +28,56 @@
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.item:hover:not(:disabled) {
|
||||
.item:hover:not(:disabled),
|
||||
.itemFocused:not(:disabled) {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.itemDanger { color: var(--color-error); }
|
||||
.itemDanger:hover:not(:disabled) { background: var(--color-error-bg); color: var(--color-error); }
|
||||
|
||||
.itemDisabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
.itemIcon {
|
||||
/* Icon area — fixed-width column so labels align */
|
||||
.itemIconWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
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 {
|
||||
height: 1px;
|
||||
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 s from "./ContextMenu.module.css";
|
||||
|
||||
@@ -30,36 +30,62 @@ interface 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(() => {
|
||||
function onDown(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
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("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
}, [onClose, focused, actionable, items]);
|
||||
|
||||
// Adjust position so menu doesn't clip outside viewport.
|
||||
// Compensate for CSS zoom (applied via document.documentElement.style.zoom)
|
||||
// because clientX/Y are pre-zoom pixels while `position:fixed` is post-zoom.
|
||||
const style = useCallback(() => {
|
||||
// Focus first item on open
|
||||
useEffect(() => {
|
||||
if (actionable.length) setFocused(actionable[0]);
|
||||
}, []);
|
||||
|
||||
const getPosition = useCallback(() => {
|
||||
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
||||
const scaledX = x / zoom;
|
||||
const scaledY = y / zoom;
|
||||
const menuW = 200;
|
||||
const menuH = items.length * 36;
|
||||
const menuH = items.length * 34;
|
||||
const vw = window.innerWidth / zoom;
|
||||
const vh = window.innerHeight / zoom;
|
||||
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
||||
@@ -71,7 +97,7 @@ export default function ContextMenu({ x, y, items, onClose }: Props) {
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={s.menu}
|
||||
style={style()}
|
||||
style={getPosition()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
@@ -79,14 +105,24 @@ export default function ContextMenu({ x, y, items, onClose }: Props) {
|
||||
return <div key={i} className={s.separator} />;
|
||||
}
|
||||
const mi = item as ContextMenuItem;
|
||||
const isFocused = focused === i;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className={[s.item, mi.danger ? s.itemDanger : "", mi.disabled ? s.itemDisabled : ""].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(); } }}
|
||||
onMouseEnter={() => !mi.disabled && setFocused(i)}
|
||||
onMouseLeave={() => setFocused(-1)}
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,7 @@ import Library from "../pages/Library";
|
||||
import SeriesDetail from "../pages/SeriesDetail";
|
||||
import History from "../pages/History";
|
||||
import Search from "../pages/Search";
|
||||
import SourceList from "../sources/SourceList";
|
||||
import SourceBrowse from "../sources/SourceBrowse";
|
||||
import Explore from "../sources/Explore";
|
||||
import DownloadQueue from "../downloads/DownloadQueue";
|
||||
import ExtensionList from "../extensions/ExtensionList";
|
||||
import s from "./Layout.module.css";
|
||||
@@ -13,16 +12,15 @@ import s from "./Layout.module.css";
|
||||
export default function Layout() {
|
||||
const navPage = useStore((s) => s.navPage);
|
||||
const activeManga = useStore((s) => s.activeManga);
|
||||
const activeSource = useStore((s) => s.activeSource);
|
||||
|
||||
function renderContent() {
|
||||
if (navPage === "library" && activeManga) return <SeriesDetail />;
|
||||
if (navPage === "sources" && activeSource) return <SourceBrowse />;
|
||||
switch (navPage) {
|
||||
case "library": return <Library />;
|
||||
case "search": return <Search />;
|
||||
case "history": return <History />;
|
||||
case "sources": return <SourceList />;
|
||||
case "sources": return <Explore />;
|
||||
case "explore": return <Explore />;
|
||||
case "downloads": return <DownloadQueue />;
|
||||
case "extensions": return <ExtensionList />;
|
||||
default: return <Library />;
|
||||
|
||||
@@ -9,7 +9,7 @@ const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||
{ id: "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: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||
];
|
||||
@@ -24,7 +24,7 @@ export default function Sidebar() {
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
setNavPage(id);
|
||||
if (id !== "sources") setActiveSource(null);
|
||||
if (id !== "explore") setActiveSource(null);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
|
||||
@@ -12,16 +12,25 @@
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
|
||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 5px 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;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.searchClear {
|
||||
position: absolute; right: 7px;
|
||||
color: var(--text-faint); font-size: 14px; line-height: 1;
|
||||
background: none; border: none; cursor: pointer; padding: 2px;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.searchClear:hover { color: var(--text-muted); }
|
||||
|
||||
.clearBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||
@@ -47,11 +56,24 @@
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.row:hover .playIcon { opacity: 1; }
|
||||
|
||||
/* Thumb with session count badge */
|
||||
.thumbWrap { position: relative; flex-shrink: 0; }
|
||||
.thumb {
|
||||
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
||||
object-fit: cover; flex-shrink: 0; background: var(--bg-raised);
|
||||
object-fit: cover; display: block; background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.sessionBadge {
|
||||
position: absolute; bottom: -4px; right: -6px;
|
||||
background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 1px 4px; border-radius: 6px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||
.mangaTitle {
|
||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||
@@ -59,11 +81,19 @@
|
||||
}
|
||||
.chapterName {
|
||||
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 {
|
||||
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 {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
|
||||
@@ -1,66 +1,118 @@
|
||||
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 { useStore, type HistoryEntry } from "../../store";
|
||||
import s from "./History.module.css";
|
||||
|
||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "Just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
if (m < 1) return "Just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// Group entries by day
|
||||
function groupByDay(entries: HistoryEntry[]): { label: string; items: HistoryEntry[] }[] {
|
||||
const groups = new Map<string, HistoryEntry[]>();
|
||||
for (const e of entries) {
|
||||
const d = new Date(e.readAt);
|
||||
const now = new Date();
|
||||
let label: string;
|
||||
if (d.toDateString() === now.toDateString()) label = "Today";
|
||||
else {
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) label = "Yesterday";
|
||||
else label = d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||
function dayLabel(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return "Today";
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
// ── Session grouping ──────────────────────────────────────────────────────────
|
||||
// Consecutive entries for the same manga within SESSION_GAP_MS are collapsed
|
||||
// into one session card showing the chapter range read.
|
||||
|
||||
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
export interface ReadingSession {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
latestChapterId: number;
|
||||
latestChapterName: string;
|
||||
latestPageNumber: number;
|
||||
firstChapterName: string;
|
||||
chapterCount: number;
|
||||
readAt: number;
|
||||
}
|
||||
|
||||
function buildSessions(entries: HistoryEntry[]): ReadingSession[] {
|
||||
if (!entries.length) return [];
|
||||
const sessions: ReadingSession[] = [];
|
||||
let i = 0;
|
||||
while (i < entries.length) {
|
||||
const anchor = entries[i];
|
||||
const group: HistoryEntry[] = [anchor];
|
||||
let j = i + 1;
|
||||
while (j < entries.length) {
|
||||
const next = entries[j];
|
||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
||||
group.push(next);
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const latest = group[0];
|
||||
const oldest = group[group.length - 1];
|
||||
sessions.push({
|
||||
mangaId: latest.mangaId,
|
||||
mangaTitle: latest.mangaTitle,
|
||||
thumbnailUrl: latest.thumbnailUrl,
|
||||
latestChapterId: latest.chapterId,
|
||||
latestChapterName: latest.chapterName,
|
||||
latestPageNumber: latest.pageNumber,
|
||||
firstChapterName: oldest.chapterName,
|
||||
chapterCount: group.length,
|
||||
readAt: latest.readAt,
|
||||
});
|
||||
i = j;
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function groupSessionsByDay(sessions: ReadingSession[]): { label: string; items: ReadingSession[] }[] {
|
||||
const groups = new Map<string, ReadingSession[]>();
|
||||
for (const sess of sessions) {
|
||||
const label = dayLabel(sess.readAt);
|
||||
if (!groups.has(label)) groups.set(label, []);
|
||||
groups.get(label)!.push(e);
|
||||
groups.get(label)!.push(sess);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function History() {
|
||||
const history = useStore((s) => s.history);
|
||||
const clearHistory = useStore((s) => s.clearHistory);
|
||||
const history = useStore((s) => s.history);
|
||||
const clearHistory = useStore((s) => s.clearHistory);
|
||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||
const setNavPage = useStore((s) => s.setNavPage);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
search.trim()
|
||||
? history.filter((e) =>
|
||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
||||
e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
||||
: history,
|
||||
[history, search]
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return history;
|
||||
return history.filter(
|
||||
(e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q)
|
||||
);
|
||||
}, [history, search]);
|
||||
|
||||
const groups = useMemo(() => groupByDay(filtered), [filtered]);
|
||||
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
||||
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
||||
|
||||
function resumeReading(entry: HistoryEntry) {
|
||||
// Navigate to manga detail — user can continue from there
|
||||
setActiveManga({
|
||||
id: entry.mangaId,
|
||||
title: entry.mangaTitle,
|
||||
thumbnailUrl: entry.thumbnailUrl,
|
||||
} as any);
|
||||
function resumeReading(session: ReadingSession) {
|
||||
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||
setNavPage("library");
|
||||
}
|
||||
|
||||
@@ -73,6 +125,9 @@ export default function History() {
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input className={s.search} placeholder="Search history…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
||||
)}
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
||||
@@ -85,11 +140,12 @@ export default function History() {
|
||||
{history.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No reading history yet.</p>
|
||||
<p className={s.emptyHint}>Chapters you read will appear here.</p>
|
||||
<p className={s.emptyText}>No reading history yet</p>
|
||||
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
) : sessions.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<Books size={28} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No results for "{search}"</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -97,20 +153,38 @@ export default function History() {
|
||||
{groups.map(({ label, items }) => (
|
||||
<div key={label} className={s.group}>
|
||||
<p className={s.groupLabel}>{label}</p>
|
||||
{items.map((entry) => (
|
||||
<button key={`${entry.chapterId}-${entry.readAt}`}
|
||||
className={s.row} onClick={() => resumeReading(entry)}>
|
||||
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle}
|
||||
className={s.thumb} />
|
||||
{items.map((session) => (
|
||||
<button
|
||||
key={`${session.latestChapterId}-${session.readAt}`}
|
||||
className={s.row}
|
||||
onClick={() => resumeReading(session)}
|
||||
>
|
||||
<div className={s.thumbWrap}>
|
||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} className={s.thumb} />
|
||||
{session.chapterCount > 1 && (
|
||||
<span className={s.sessionBadge}>{session.chapterCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={s.info}>
|
||||
<span className={s.mangaTitle}>{entry.mangaTitle}</span>
|
||||
<span className={s.chapterName}>{entry.chapterName}
|
||||
{entry.pageNumber > 1 && (
|
||||
<span className={s.pageBadge}>p.{entry.pageNumber}</span>
|
||||
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
||||
<span className={s.chapterName}>
|
||||
{session.chapterCount > 1 ? (
|
||||
<span className={s.chapterRange}>
|
||||
{session.firstChapterName}
|
||||
<span className={s.rangeSep}>→</span>
|
||||
{session.latestChapterName}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{session.latestChapterName}
|
||||
{session.latestPageNumber > 1 && (
|
||||
<span className={s.pageBadge}>p.{session.latestPageNumber}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={s.time}>{timeAgo(entry.readAt)}</span>
|
||||
<span className={s.time}>{timeAgo(session.readAt)}</span>
|
||||
<Play size={12} weight="fill" className={s.playIcon} />
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -107,22 +107,32 @@
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
/* Virtual row — flexbox instead of CSS grid so virtualizer controls height */
|
||||
.virtualRow {
|
||||
display: flex;
|
||||
gap: var(--sp-4);
|
||||
/* Contain stacking contexts for GPU layers */
|
||||
contain: layout style;
|
||||
padding: 0 var(--sp-6);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Individual card fills its flex slot */
|
||||
.card {
|
||||
flex: 1 1 130px;
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
/* 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); }
|
||||
@@ -177,38 +187,12 @@
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
/* Show more */
|
||||
.showMore {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-6) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.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 grid still uses CSS grid since it's fixed 12 items */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-6) 0;
|
||||
}
|
||||
|
||||
/* Skeleton */
|
||||
@@ -225,6 +209,14 @@
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
/* Ghost cards fill trailing grid space without taking interaction */
|
||||
.ghostCard {
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+116
-105
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useState, useMemo, useCallback, memo } from "react";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash } from "@phosphor-icons/react";
|
||||
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
||||
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import s from "./Library.module.css";
|
||||
|
||||
const INITIAL_PAGE_SIZE = 48;
|
||||
const PAGE_INCREMENT = 48;
|
||||
// Keep in sync with CSS grid: minmax(130px, 1fr) + var(--sp-4)=16px gap
|
||||
const CARD_MIN_W = 130;
|
||||
const CARD_GAP = 16;
|
||||
const ROW_HEIGHT = 260; // ~195px cover + ~40px title + 16px gap + buffer
|
||||
|
||||
// Memoized card to prevent re-renders when siblings change
|
||||
const MangaCard = memo(function MangaCard({
|
||||
manga,
|
||||
onClick,
|
||||
@@ -43,78 +45,89 @@ const MangaCard = memo(function MangaCard({
|
||||
});
|
||||
|
||||
export default function Library() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||
const folders = useStore((state) => state.settings.folders);
|
||||
const folders = useStore((state) => state.settings.folders);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
])
|
||||
.then(([all, lib]) => {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m));
|
||||
})
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
|
||||
.then((lib) => setAllManga(lib.mangas.nodes))
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { setVisibleCount(INITIAL_PAGE_SIZE); }, [libraryFilter, search]);
|
||||
// Reset scroll when filter/search changes
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
}, [libraryFilter, search]);
|
||||
|
||||
// Reset filter if the active folder tab gets hidden
|
||||
useEffect(() => {
|
||||
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
||||
if (activeFolder && !activeFolder.showTab) {
|
||||
setLibraryFilter("library");
|
||||
}
|
||||
if (activeFolder && !activeFolder.showTab) setLibraryFilter("library");
|
||||
}, [folders]);
|
||||
|
||||
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items = allManga;
|
||||
|
||||
if (libraryFilter === "library") {
|
||||
items = items.filter((m) => m.inLibrary);
|
||||
} else if (libraryFilter === "downloaded") {
|
||||
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
||||
} else if (!isBuiltinFilter) {
|
||||
// folder filter
|
||||
const folder = folders.find((f) => f.id === libraryFilter);
|
||||
if (folder) {
|
||||
items = items.filter((m) => folder.mangaIds.includes(m.id));
|
||||
}
|
||||
if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id));
|
||||
}
|
||||
|
||||
// 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 (libraryTagFilter.length > 0)
|
||||
items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag)));
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
||||
|
||||
const visible = filtered.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
||||
// We need to know columns to chunk filtered into rows.
|
||||
// Use a ResizeObserver on the scroll container to get real width.
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const result: Manga[][] = [];
|
||||
for (let i = 0; i < filtered.length; i += cols)
|
||||
result.push(filtered.slice(i, i + cols));
|
||||
return result;
|
||||
}, [filtered, cols]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 3,
|
||||
});
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(m: Manga) => () => setActiveManga(m),
|
||||
@@ -129,25 +142,17 @@ export default function Library() {
|
||||
async function deleteAllDownloads(manga: Manga) {
|
||||
try {
|
||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
||||
const downloadedIds = data.chapters.nodes
|
||||
.filter((c) => c.isDownloaded)
|
||||
.map((c) => c.id);
|
||||
if (!downloadedIds.length) return;
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: downloadedIds });
|
||||
setAllManga((prev) =>
|
||||
prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
const ids = data.chapters.nodes.filter((c) => c.isDownloaded).map((c) => c.id);
|
||||
if (!ids.length) return;
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault();
|
||||
const menuW = 200;
|
||||
const menuH = 160;
|
||||
const x = Math.min(e.clientX, window.innerWidth - menuW - 8);
|
||||
const y = Math.min(e.clientY, window.innerHeight - menuH - 8);
|
||||
const x = Math.min(e.clientX, window.innerWidth - 208);
|
||||
const y = Math.min(e.clientY, window.innerHeight - 168);
|
||||
setCtx({ x, y, manga: m });
|
||||
}
|
||||
|
||||
@@ -155,11 +160,13 @@ export default function Library() {
|
||||
return [
|
||||
{
|
||||
label: "Open",
|
||||
icon: <BookOpen size={13} weight="light" />,
|
||||
onClick: () => setActiveManga(m),
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: m.inLibrary ? "Remove from library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
danger: m.inLibrary,
|
||||
onClick: () => m.inLibrary
|
||||
? removeFromLibrary(m)
|
||||
@@ -169,9 +176,9 @@ export default function Library() {
|
||||
},
|
||||
{
|
||||
label: "Delete all downloads",
|
||||
icon: <Trash size={13} weight="light" />,
|
||||
danger: true,
|
||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
||||
icon: <Trash size={13} weight="light" />,
|
||||
onClick: () => deleteAllDownloads(m),
|
||||
},
|
||||
];
|
||||
@@ -189,9 +196,7 @@ export default function Library() {
|
||||
library: allManga.filter((m) => m.inLibrary).length,
|
||||
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
||||
};
|
||||
folders.forEach((f) => {
|
||||
result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length;
|
||||
});
|
||||
folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; });
|
||||
return result;
|
||||
}, [allManga, folders]);
|
||||
|
||||
@@ -203,12 +208,11 @@ export default function Library() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.root} ref={scrollRef}>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Library</h1>
|
||||
<div className={s.tabs}>
|
||||
{/* Built-in tabs */}
|
||||
{(["library", "downloaded", "all"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
@@ -219,13 +223,10 @@ export default function Library() {
|
||||
<><Books size={11} weight="bold" /> Saved</>
|
||||
) : f === "downloaded" ? (
|
||||
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
||||
) : (
|
||||
<>All</>
|
||||
)}
|
||||
) : <>All</>}
|
||||
<span className={s.tabCount}>{counts[f]}</span>
|
||||
</button>
|
||||
))}
|
||||
{/* Folder tabs — only shown if the folder has showTab enabled */}
|
||||
{folders.filter((f) => f.showTab).map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
@@ -250,13 +251,11 @@ export default function Library() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag filter panel */}
|
||||
{allTags.length > 0 && (
|
||||
<div className={s.tagPanel}>
|
||||
{libraryTagFilter.length > 0 && (
|
||||
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
||||
<X size={11} weight="bold" />
|
||||
Clear
|
||||
<X size={11} weight="bold" /> Clear
|
||||
</button>
|
||||
)}
|
||||
{allTags.map((tag) => {
|
||||
@@ -264,13 +263,9 @@ export default function Library() {
|
||||
return (
|
||||
<button key={tag}
|
||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
||||
onClick={() =>
|
||||
setLibraryTagFilter(
|
||||
active
|
||||
? libraryTagFilter.filter((t) => t !== tag)
|
||||
: [...libraryTagFilter, tag]
|
||||
)
|
||||
}>
|
||||
onClick={() => setLibraryTagFilter(
|
||||
active ? libraryTagFilter.filter((t) => t !== tag) : [...libraryTagFilter, tag]
|
||||
)}>
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
@@ -298,31 +293,47 @@ export default function Library() {
|
||||
: "No manga found."}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={s.grid}>
|
||||
{visible.map((m) => (
|
||||
<MangaCard
|
||||
key={m.id}
|
||||
manga={m}
|
||||
onClick={handleCardClick(m)}
|
||||
onContextMenu={(e) => openCtx(e, m)}
|
||||
cropCovers={settings.libraryCropCovers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className={s.showMore}>
|
||||
<button
|
||||
className={s.showMoreBtn}
|
||||
onClick={() => setVisibleCount((c) => c + PAGE_INCREMENT)}
|
||||
/* Virtual scroll container */
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const rowManga = rows[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: virtualRow.start,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
className={s.virtualRow}
|
||||
>
|
||||
Show more
|
||||
<span className={s.showMoreCount}>{filtered.length - visibleCount} remaining</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{rowManga.map((m) => (
|
||||
<MangaCard
|
||||
key={m.id}
|
||||
manga={m}
|
||||
onClick={handleCardClick(m)}
|
||||
onContextMenu={(e) => openCtx(e, m)}
|
||||
cropCovers={settings.libraryCropCovers}
|
||||
/>
|
||||
))}
|
||||
{/* Ghost cards on last row to fill grid */}
|
||||
{virtualRow.index === rows.length - 1 &&
|
||||
Array.from({ length: cols - rowManga.length }).map((_, i) => (
|
||||
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu
|
||||
x={ctx.x}
|
||||
|
||||
@@ -206,7 +206,7 @@ export default function Reader() {
|
||||
const [dlOpen, setDlOpen] = useState(false);
|
||||
const [zoomOpen, setZoomOpen] = useState(false);
|
||||
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[][]>([]);
|
||||
// True only after the first page of the new chapter has been decoded,
|
||||
// preventing any flash of the previous chapter's image.
|
||||
@@ -319,6 +319,7 @@ export default function Reader() {
|
||||
setLoading(true); setError(null); setPageGroups([]); setPageReady(false);
|
||||
// Reset strip state for new chapter navigation (non-scroll transitions)
|
||||
appendedRef.current = new Set();
|
||||
markedReadRef.current = new Set();
|
||||
|
||||
const targetId = activeChapter.id;
|
||||
loadingChapterRef.current = targetId;
|
||||
@@ -483,11 +484,13 @@ export default function Reader() {
|
||||
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
|
||||
});
|
||||
}
|
||||
if (settings.autoMarkRead && pageNumber === lastPage && !markedRead.has(activeChapter.id)) {
|
||||
setMarkedRead((p) => new Set(p).add(activeChapter.id));
|
||||
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
|
||||
if (settings.autoMarkRead && pageNumber === lastPage) {
|
||||
if (!markedReadRef.current.has(activeChapter.id)) {
|
||||
markedReadRef.current.add(activeChapter.id);
|
||||
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
|
||||
}
|
||||
}
|
||||
}, [pageNumber, lastPage, activeChapter?.id]);
|
||||
}, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead]);
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||
const advanceGroup = useCallback((forward: boolean) => {
|
||||
@@ -575,6 +578,23 @@ export default function Reader() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl += / Ctrl + / Ctrl - / Ctrl 0 → zoom
|
||||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) {
|
||||
e.preventDefault();
|
||||
updateSettings({ maxPageWidth: Math.min(2400, maxW + 100) });
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey && e.key === "-") {
|
||||
e.preventDefault();
|
||||
updateSettings({ maxPageWidth: Math.max(200, maxW - 100) });
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey && e.key === "0") {
|
||||
e.preventDefault();
|
||||
updateSettings({ maxPageWidth: 900 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||||
@@ -589,7 +609,7 @@ export default function Reader() {
|
||||
};
|
||||
window.addEventListener("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]);
|
||||
|
||||
// ── Longstrip scroll tracker ─────────────────────────────────────────────────
|
||||
// Tracks current page number. In autoNext mode, appends the next chapter's
|
||||
@@ -635,11 +655,10 @@ export default function Reader() {
|
||||
if (settings.autoMarkRead) {
|
||||
const prevChunk = strip[strip.indexOf(chunk) - 1];
|
||||
if (prevChunk) {
|
||||
setMarkedRead((r) => {
|
||||
if (r.has(prevChunk.chapterId)) return r;
|
||||
if (!markedReadRef.current.has(prevChunk.chapterId)) {
|
||||
markedReadRef.current.add(prevChunk.chapterId);
|
||||
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
||||
return new Set(r).add(prevChunk.chapterId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||
import {
|
||||
ArrowLeft, BookmarkSimple, Download, CheckCircle,
|
||||
ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle,
|
||||
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
||||
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
||||
List, SquaresFour, FolderSimplePlus, X, Trash,
|
||||
List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
@@ -277,16 +277,23 @@ export default function SeriesDetail() {
|
||||
return [
|
||||
{
|
||||
label: ch.isRead ? "Mark as unread" : "Mark as read",
|
||||
icon: ch.isRead
|
||||
? <Circle size={13} weight="light" />
|
||||
: <CheckCircle size={13} weight="light" />,
|
||||
onClick: () => markRead(ch.id, !ch.isRead),
|
||||
},
|
||||
{
|
||||
label: "Mark all above as read",
|
||||
icon: <CheckCircle size={13} weight="duotone" />,
|
||||
onClick: () => markAllAboveRead(indexInSorted),
|
||||
disabled: indexInSorted === 0,
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: ch.isDownloaded ? "Delete download" : "Download",
|
||||
icon: ch.isDownloaded
|
||||
? <Trash size={13} weight="light" />
|
||||
: <Download size={13} weight="light" />,
|
||||
onClick: () => ch.isDownloaded
|
||||
? deleteDownloaded(ch.id)
|
||||
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
|
||||
@@ -295,6 +302,7 @@ export default function SeriesDetail() {
|
||||
{ separator: true },
|
||||
{
|
||||
label: "Download all from here",
|
||||
icon: <DownloadSimple size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const fromHere = sortedChapters
|
||||
.slice(indexInSorted)
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import { useEffect, useState, useMemo, memo } from "react";
|
||||
import { ArrowLeft, ArrowRight, Compass, List, BookOpen, Star, Fire } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import SourceList from "./SourceList";
|
||||
import SourceBrowse from "./SourceBrowse";
|
||||
import s from "./Explore.module.css";
|
||||
|
||||
// ── Frecency ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function frecencyScore(readAt: number, count: number): number {
|
||||
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
||||
return count / Math.log(hoursSince + 2);
|
||||
}
|
||||
|
||||
// ── Ghost card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function GhostCard() {
|
||||
return <div className={s.ghostCard} aria-hidden />;
|
||||
}
|
||||
|
||||
const GHOST_COUNT = 3;
|
||||
|
||||
// ── Skeleton row ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.skeletonRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mini card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MiniCard = memo(function MiniCard({
|
||||
manga,
|
||||
onClick,
|
||||
subtitle,
|
||||
progress,
|
||||
}: {
|
||||
manga: Manga;
|
||||
onClick: () => void;
|
||||
subtitle?: string;
|
||||
progress?: number;
|
||||
}) {
|
||||
return (
|
||||
<button className={s.card} onClick={onClick}>
|
||||
<div className={s.coverWrap}>
|
||||
<img
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.cover}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
{progress !== undefined && progress > 0 && (
|
||||
<div className={s.progressBar}>
|
||||
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.title}>{manga.title}</p>
|
||||
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Genre drill-down ──────────────────────────────────────────────────────────
|
||||
|
||||
function GenreDrill({
|
||||
genre,
|
||||
manga,
|
||||
sourceManga,
|
||||
onBack,
|
||||
onOpen,
|
||||
}: {
|
||||
genre: string;
|
||||
manga: Manga[];
|
||||
sourceManga: Manga[];
|
||||
onBack: () => void;
|
||||
onOpen: (m: Manga) => void;
|
||||
}) {
|
||||
const filtered = useMemo(() => {
|
||||
const combined = new Map<number, Manga>();
|
||||
[...manga, ...sourceManga]
|
||||
.filter((m) => (m.genre ?? []).includes(genre))
|
||||
.forEach((m) => combined.set(m.id, m));
|
||||
return Array.from(combined.values());
|
||||
}, [manga, sourceManga, genre]);
|
||||
|
||||
return (
|
||||
<div className={s.drillRoot}>
|
||||
<div className={s.drillHeader}>
|
||||
<button className={s.back} onClick={onBack}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<span>Explore</span>
|
||||
</button>
|
||||
<span className={s.drillTitle}>{genre}</span>
|
||||
</div>
|
||||
<div className={s.drillGrid}>
|
||||
{filtered.map((m) => (
|
||||
<button key={m.id} className={s.drillCard} onClick={() => onOpen(m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<img
|
||||
src={thumbUrl(m.thumbnailUrl)}
|
||||
alt={m.title}
|
||||
className={s.cover}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
</div>
|
||||
<p className={s.title}>{m.title}</p>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className={s.empty}>No manga found for {genre}.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
title,
|
||||
icon,
|
||||
onSeeAll,
|
||||
loading,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
onSeeAll?: () => void;
|
||||
loading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.section}>
|
||||
<div className={s.sectionHeader}>
|
||||
<span className={s.sectionTitle}>
|
||||
<span className={s.sectionTitleIcon}>
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
{onSeeAll && (
|
||||
<button className={s.seeAll} onClick={onSeeAll}>
|
||||
See all <ArrowRight size={11} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? <SkeletonRow /> : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ExploreMode = "explore" | "sources";
|
||||
type DrillState = { type: "genre"; genre: string } | null;
|
||||
|
||||
export default function Explore() {
|
||||
const [mode, setMode] = useState<ExploreMode>("explore");
|
||||
const [drill, setDrill] = useState<DrillState>(null);
|
||||
const activeSource = useStore((s) => s.activeSource);
|
||||
|
||||
if (activeSource) return <SourceBrowse />;
|
||||
|
||||
if (drill?.type === "genre" && mode === "explore") {
|
||||
return <DrillWrapper drill={drill} onBack={() => setDrill(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Explore</h1>
|
||||
<div className={s.tabs}>
|
||||
<button
|
||||
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("explore")}
|
||||
>
|
||||
<Compass size={11} weight="bold" />
|
||||
Explore
|
||||
</button>
|
||||
<button
|
||||
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("sources")}
|
||||
>
|
||||
<List size={11} weight="bold" />
|
||||
Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "explore" ? <ExploreFeed onDrill={setDrill} /> : <SourceList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Drill wrapper ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DrillWrapper({ drill, onBack }: { drill: DrillState; onBack: () => void }) {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||
const setNavPage = useStore((s) => s.setNavPage);
|
||||
const settings = useStore((s) => s.settings);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
]).then(([all, lib]) => {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m));
|
||||
}).catch(console.error);
|
||||
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => {
|
||||
const all = d.sources.nodes.filter((src) => src.id !== "0");
|
||||
const byName = new Map<string, Source[]>();
|
||||
for (const src of all) {
|
||||
if (!byName.has(src.name)) byName.set(src.name, []);
|
||||
byName.get(src.name)!.push(src);
|
||||
}
|
||||
const picked: Source[] = [];
|
||||
for (const group of byName.values()) {
|
||||
const preferred = group.find((s) => s.lang === preferredLang);
|
||||
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||
}
|
||||
return Promise.allSettled(
|
||||
picked.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "POPULAR", page: 1, query: null,
|
||||
}).then((d) => d.fetchSourceManga.mangas)
|
||||
)
|
||||
);
|
||||
})
|
||||
.then((results) => {
|
||||
const seen = new Set<number>();
|
||||
const merged: Manga[] = [];
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled")
|
||||
for (const m of r.value)
|
||||
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
||||
}
|
||||
setSourceManga(merged);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
if (!drill) return null;
|
||||
|
||||
return (
|
||||
<GenreDrill
|
||||
genre={drill.genre}
|
||||
manga={allManga}
|
||||
sourceManga={sourceManga}
|
||||
onBack={onBack}
|
||||
onOpen={(m) => { setActiveManga(m); setNavPage("library"); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Explore feed ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loadingLib, setLoadingLib] = useState(true);
|
||||
// Popular row: deduped results from POPULAR fetch across all sources
|
||||
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
||||
const [loadingPopular, setLoadingPopular] = useState(true);
|
||||
// Genre search results: genre → merged Manga[] from SEARCH per source
|
||||
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
|
||||
const history = useStore((s) => s.history);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||
const setNavPage = useStore((s) => s.setNavPage);
|
||||
|
||||
// Load library
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
])
|
||||
.then(([all, lib]) => {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m));
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingLib(false));
|
||||
}, []);
|
||||
|
||||
// Load sources → fetch POPULAR from all (for popular row),
|
||||
// then once we know frecency genres, fire SEARCH per genre per source
|
||||
useEffect(() => {
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => {
|
||||
const all = d.sources.nodes.filter((src) => src.id !== "0");
|
||||
|
||||
// Dedupe by name, pick preferred lang
|
||||
const byName = new Map<string, Source[]>();
|
||||
for (const src of all) {
|
||||
if (!byName.has(src.name)) byName.set(src.name, []);
|
||||
byName.get(src.name)!.push(src);
|
||||
}
|
||||
const picked: Source[] = [];
|
||||
for (const group of byName.values()) {
|
||||
const preferred = group.find((s) => s.lang === preferredLang);
|
||||
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||
}
|
||||
|
||||
setSources(picked);
|
||||
if (picked.length === 0) { setLoadingPopular(false); return; }
|
||||
|
||||
// Fetch POPULAR from all sources for the popular row
|
||||
return Promise.allSettled(
|
||||
picked.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "POPULAR", page: 1, query: null,
|
||||
}).then((d) => d.fetchSourceManga.mangas)
|
||||
)
|
||||
).then((results) => {
|
||||
const seen = new Set<number>();
|
||||
const merged: Manga[] = [];
|
||||
for (const r of results)
|
||||
if (r.status === "fulfilled")
|
||||
for (const m of r.value)
|
||||
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
||||
setPopularManga(merged.slice(0, 30));
|
||||
// Return picked sources for genre search phase
|
||||
return picked;
|
||||
});
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingPopular(false));
|
||||
}, []);
|
||||
|
||||
// Once library loaded AND sources ready, search each frecency genre across sources
|
||||
const frecencyGenres = useMemo(() => {
|
||||
const mangaScores = new Map<number, number>();
|
||||
const mangaReadAt = new Map<number, number>();
|
||||
for (const entry of history) {
|
||||
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
|
||||
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
|
||||
mangaReadAt.set(entry.mangaId, entry.readAt);
|
||||
}
|
||||
const genreWeights = new Map<string, number>();
|
||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||
for (const [mangaId, count] of mangaScores.entries()) {
|
||||
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
||||
for (const genre of mangaMap.get(mangaId)?.genre ?? [])
|
||||
genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score);
|
||||
}
|
||||
if (genreWeights.size === 0) {
|
||||
allManga.filter((m) => m.inLibrary).forEach((m) =>
|
||||
(m.genre ?? []).forEach((g) =>
|
||||
genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
||||
}
|
||||
return Array.from(genreWeights.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3) // top 3 genres only
|
||||
.map(([g]) => g);
|
||||
}, [allManga, history]);
|
||||
|
||||
// Fire genre searches once we have both genres and sources
|
||||
useEffect(() => {
|
||||
if (frecencyGenres.length === 0 || sources.length === 0) return;
|
||||
setLoadingGenres(true);
|
||||
|
||||
// For each genre, search all sources concurrently, then merge results
|
||||
// Cap to top 3 sources to limit requests (3 genres × 3 sources = 9 searches max)
|
||||
const searchSources = sources.slice(0, 3);
|
||||
|
||||
Promise.allSettled(
|
||||
frecencyGenres.map((genre) =>
|
||||
Promise.allSettled(
|
||||
searchSources.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "SEARCH", page: 1, query: genre,
|
||||
}).then((d) => d.fetchSourceManga.mangas)
|
||||
)
|
||||
).then((results) => {
|
||||
const seen = new Set<number>();
|
||||
const merged: Manga[] = [];
|
||||
for (const r of results)
|
||||
if (r.status === "fulfilled")
|
||||
for (const m of r.value)
|
||||
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
||||
return { genre, mangas: merged.slice(0, 24) };
|
||||
})
|
||||
)
|
||||
).then((results) => {
|
||||
const map = new Map<string, Manga[]>();
|
||||
for (const r of results)
|
||||
if (r.status === "fulfilled")
|
||||
map.set(r.value.genre, r.value.mangas);
|
||||
setGenreResults(map);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingGenres(false));
|
||||
}, [frecencyGenres.join(","), sources.map((s) => s.id).join(",")]);
|
||||
|
||||
function openManga(m: Manga) {
|
||||
setActiveManga(m);
|
||||
setNavPage("library");
|
||||
}
|
||||
|
||||
// ── Continue reading ────────────────────────────────────────────────────
|
||||
const continueReading = useMemo(() => {
|
||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||
const seen = new Set<number>();
|
||||
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
||||
for (const entry of history) {
|
||||
if (seen.has(entry.mangaId)) continue;
|
||||
seen.add(entry.mangaId);
|
||||
const manga = mangaMap.get(entry.mangaId);
|
||||
if (!manga) continue;
|
||||
result.push({
|
||||
manga,
|
||||
chapterName: entry.chapterName,
|
||||
progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0,
|
||||
});
|
||||
if (result.length >= 12) break;
|
||||
}
|
||||
return result;
|
||||
}, [history, allManga]);
|
||||
|
||||
// ── Recommended (frecency) ──────────────────────────────────────────────
|
||||
const recommended = useMemo(() => {
|
||||
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
|
||||
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
||||
return allManga
|
||||
.filter((m) => m.inLibrary && !continueIds.has(m.id) &&
|
||||
frecencyGenres.some((g) => (m.genre ?? []).includes(g)))
|
||||
.slice(0, 20);
|
||||
}, [allManga, frecencyGenres, continueReading]);
|
||||
|
||||
const genresLoading = loadingLib || loadingGenres;
|
||||
|
||||
return (
|
||||
<div className={s.body}>
|
||||
|
||||
{/* Continue Reading */}
|
||||
{(continueReading.length > 0 || loadingLib) && (
|
||||
<Section
|
||||
title="Continue Reading"
|
||||
icon={<BookOpen size={11} weight="bold" />}
|
||||
loading={loadingLib}
|
||||
>
|
||||
<div className={s.row}>
|
||||
{continueReading.map(({ manga, chapterName, progress }) => (
|
||||
<MiniCard
|
||||
key={manga.id}
|
||||
manga={manga}
|
||||
onClick={() => openManga(manga)}
|
||||
subtitle={chapterName}
|
||||
progress={progress}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||
<GhostCard key={`ghost-cr-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Recommended */}
|
||||
{(recommended.length > 0 || loadingLib) && (
|
||||
<Section
|
||||
title="Recommended for You"
|
||||
icon={<Star size={11} weight="bold" />}
|
||||
loading={loadingLib}
|
||||
>
|
||||
<div className={s.row}>
|
||||
{recommended.map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||
<GhostCard key={`ghost-rec-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Popular across deduplicated sources */}
|
||||
{(popularManga.length > 0 || loadingPopular) && (
|
||||
<Section
|
||||
title={
|
||||
sources.length === 1
|
||||
? `Popular on ${sources[0].displayName}`
|
||||
: sources.length > 1
|
||||
? `Popular across ${sources.length} sources`
|
||||
: "Popular"
|
||||
}
|
||||
icon={<Fire size={11} weight="bold" />}
|
||||
loading={loadingPopular}
|
||||
>
|
||||
{sources.length === 0 ? (
|
||||
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||
) : (
|
||||
<div className={s.row}>
|
||||
{popularManga.map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||
<GhostCard key={`ghost-pop-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Genre rows — searched from sources by genre name */}
|
||||
{frecencyGenres.map((genre) => {
|
||||
const items = genreResults.get(genre) ?? [];
|
||||
const isLoading = genresLoading && items.length === 0;
|
||||
if (!isLoading && items.length === 0) return null;
|
||||
return (
|
||||
<Section
|
||||
key={genre}
|
||||
title={genre}
|
||||
onSeeAll={() => onDrill({ type: "genre", genre })}
|
||||
loading={isLoading}
|
||||
>
|
||||
<div className={s.row}>
|
||||
{items.map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||
<GhostCard key={`ghost-${genre}-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loadingLib && !loadingPopular && !loadingGenres &&
|
||||
continueReading.length === 0 && recommended.length === 0 &&
|
||||
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
||||
<div className={s.empty}>
|
||||
<span>Nothing to explore yet</span>
|
||||
<span className={s.emptyHint}>
|
||||
Add manga to your library or install sources to get started.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const GET_LIBRARY = `
|
||||
inLibrary
|
||||
downloadCount
|
||||
unreadCount
|
||||
genre
|
||||
chapters {
|
||||
totalCount
|
||||
}
|
||||
|
||||
+9
-1
@@ -6,7 +6,7 @@ import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
||||
export type PageStyle = "single" | "double" | "longstrip";
|
||||
export type FitMode = "width" | "height" | "screen" | "original";
|
||||
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 ChapterSortDir = "desc" | "asc";
|
||||
|
||||
@@ -164,6 +164,14 @@ export const useStore = create<Store>()(
|
||||
history: [],
|
||||
addHistory: (entry) =>
|
||||
set((s) => {
|
||||
const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId);
|
||||
if (existing === 0) {
|
||||
// Same chapter is already at the top — just update pageNumber and readAt in place
|
||||
const updated = [...s.history];
|
||||
updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||
return { history: updated };
|
||||
}
|
||||
// New chapter or chapter not at top — remove old entry, prepend fresh
|
||||
const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId);
|
||||
return { history: [entry, ...deduped].slice(0, 300) };
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user