Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db44afc4dc | |||
| 4248e344ab | |||
| 8941bfef10 | |||
| 11cd6ff870 | |||
| 15adb02be3 | |||
| 51bb6cdab9 | |||
| 454a674ada | |||
| f146de5c02 | |||
| 04f680c3bb | |||
| f49f7e7ac1 | |||
| a62512bf42 | |||
| d91ed2e6d1 | |||
| 61e3c4ee2f | |||
| 9151820843 | |||
| 63c890dadf | |||
| 51a33679d5 | |||
| 82f8a9a36b | |||
| 4decce9a7f | |||
| a69d5eacc5 | |||
| 4959722759 | |||
| 35ba0171c7 | |||
| d26fa50e76 | |||
| fd9d216325 | |||
| 581eb2adb0 | |||
| 8aa2dc2547 | |||
| 0a11fe3982 | |||
| f6786def87 | |||
| 262027d9f9 | |||
| d407359973 | |||
| a77572a8d4 | |||
| 32d2fffdc5 | |||
| e850cbac1e | |||
| eebd1b6446 | |||
| 5ed072211b | |||
| 62e41e5f07 | |||
| 4b6d0780c9 | |||
| 6ef0facb89 | |||
| 34d997fc9d | |||
| 1f08b46919 | |||
| ac6b70fb32 | |||
| 2c93d8743d | |||
| b9fe54c08d | |||
| 3abb4bb96c | |||
| 4b3493465d | |||
| 2163f4a8a6 | |||
| fc535f3f74 | |||
| c819d03222 | |||
| b23292cff5 | |||
| 6d85be751a | |||
| 06a9e71a90 | |||
| 1a183e7a24 | |||
| dcb3377349 | |||
| 077ea4dd8f | |||
| 6bdf59db6a | |||
| db9ff33c64 | |||
| fb1b3d9789 | |||
| 041f735a6e | |||
| a27c20fabf | |||
| 29323c534b | |||
| a3ef693ed8 | |||
| 4691f3aed7 |
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.3.0)"
|
||||
description: "Version to build (e.g. 0.4.0)"
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
@@ -100,149 +100,86 @@ jobs:
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
|
||||
find_launcher() {
|
||||
local dir="$1"
|
||||
# v2.1.1867 macOS tarball ships "Suwayomi Launcher.command" (space, .command)
|
||||
find "$dir" -maxdepth 1 -type f -name "*.command" | head -1
|
||||
}
|
||||
stage_arch() {
|
||||
local srcdir="$1"
|
||||
local arch="$2"
|
||||
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
||||
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||
|
||||
ARM_LAUNCHER=$(find_launcher suwayomi-arm64)
|
||||
X64_LAUNCHER=$(find_launcher suwayomi-x64)
|
||||
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||
|
||||
if [ -z "$ARM_LAUNCHER" ] || [ -z "$X64_LAUNCHER" ]; then
|
||||
echo "ERROR: could not find launchers — tarball contents:"
|
||||
ls -lR suwayomi-arm64 suwayomi-x64
|
||||
if [ -z "$JAR" ]; then
|
||||
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
||||
find "$srcdir" -type f | head -30
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$JAVA" ]; then
|
||||
echo "ERROR: jre/bin/java not found in $srcdir"
|
||||
find "$srcdir" -type f | head -30
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "arm64 launcher: $ARM_LAUNCHER"
|
||||
echo "x64 launcher: $X64_LAUNCHER"
|
||||
echo "${arch}: jar=${JAR} java=${JAVA}"
|
||||
|
||||
cp "$ARM_LAUNCHER" src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
|
||||
cp "$X64_LAUNCHER" src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
|
||||
chmod +x src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
|
||||
chmod +x src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
|
||||
cp -r "$srcdir" "$bundle_dest"
|
||||
|
||||
# tauri.conf.json expects exactly "binaries/suwayomi-bundle".
|
||||
# We stage both arch bundles and swap the symlink before each build.
|
||||
cp -r suwayomi-arm64 src-tauri/binaries/suwayomi-bundle-arm64
|
||||
cp -r suwayomi-x64 src-tauri/binaries/suwayomi-bundle-x64
|
||||
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
|
||||
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
|
||||
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
|
||||
chmod +x "$sidecar"
|
||||
echo "Staged sidecar: $sidecar"
|
||||
}
|
||||
|
||||
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
||||
stage_arch suwayomi-x64 x86_64-apple-darwin
|
||||
|
||||
- name: Patch tauri.conf.json for CI
|
||||
run: |
|
||||
# dist/ is already built by the frontend job — suppress the rebuild.
|
||||
# We patch in-place rather than using --config to avoid Tauri schema issues.
|
||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||
|
||||
# ── aarch64 build ──────────────────────────────────────────────────────
|
||||
- name: Swap bundle for aarch64
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-arm64 src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
||||
src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Build Tauri app (aarch64)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
||||
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
||||
# Ad-hoc signing ("-") ships without a Developer ID.
|
||||
# Gatekeeper will quarantine the app on other Macs — users must run:
|
||||
# xattr -rd com.apple.quarantine Moku.app
|
||||
# To fix this properly, set APPLE_SIGNING_IDENTITY to your
|
||||
# "Developer ID Application: ..." cert name and add
|
||||
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID /
|
||||
# APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD secrets for notarisation.
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||
with:
|
||||
args: --target aarch64-apple-darwin
|
||||
|
||||
# ── x86_64 build ───────────────────────────────────────────────────────
|
||||
- name: Swap bundle for x86_64
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-x64 src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
||||
src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Build Tauri app (x86_64)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
||||
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||
with:
|
||||
args: --target x86_64-apple-darwin
|
||||
|
||||
# ── upload artifacts ───────────────────────────────────────────────────
|
||||
- name: Upload arm64 .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-aarch64
|
||||
name: moku-macos-arm64-${{ github.event.inputs.version }}
|
||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload x64 .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-x86_64
|
||||
name: moku-macos-x64-${{ github.event.inputs.version }}
|
||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload arm64 .app (for universal job)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-aarch64-apple-darwin
|
||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/macos/
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload x64 .app (for universal job)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-x86_64-apple-darwin
|
||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/macos/
|
||||
retention-days: 1
|
||||
|
||||
universal:
|
||||
name: Universal .dmg
|
||||
needs: tauri
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Download arm64 .app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: app-aarch64-apple-darwin
|
||||
path: apps/arm64/
|
||||
|
||||
- name: Download x64 .app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: app-x86_64-apple-darwin
|
||||
path: apps/x64/
|
||||
|
||||
- name: lipo into universal binary
|
||||
run: |
|
||||
ARM_APP=$(find apps/arm64 -name "*.app" -maxdepth 1 | head -1)
|
||||
X64_APP=$(find apps/x64 -name "*.app" -maxdepth 1 | head -1)
|
||||
APP_NAME=$(basename "$ARM_APP")
|
||||
|
||||
mkdir -p universal
|
||||
cp -r "$ARM_APP" "universal/${APP_NAME}"
|
||||
|
||||
find "universal/${APP_NAME}" -type f | while read -r f; do
|
||||
if file "$f" | grep -q "Mach-O"; then
|
||||
X64_EQUIV="${X64_APP}${f#universal/${APP_NAME}}"
|
||||
if [ -f "$X64_EQUIV" ]; then
|
||||
lipo -create -output "$f" "$f" "$X64_EQUIV" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Package universal .dmg
|
||||
run: |
|
||||
APP_NAME=$(find universal -name "*.app" -maxdepth 1 | head -1 | xargs basename)
|
||||
mkdir dmg-stage
|
||||
cp -r "universal/${APP_NAME}" dmg-stage/
|
||||
ln -s /Applications dmg-stage/Applications
|
||||
hdiutil create \
|
||||
-volname "Moku" \
|
||||
-srcfolder dmg-stage \
|
||||
-ov -format UDZO \
|
||||
"moku-universal.dmg"
|
||||
|
||||
- name: Upload universal .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-universal
|
||||
path: moku-universal.dmg
|
||||
retention-days: 7
|
||||
@@ -7,6 +7,9 @@ on:
|
||||
description: "Version to build (e.g. 0.4.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Build frontend
|
||||
@@ -93,8 +96,6 @@ jobs:
|
||||
else
|
||||
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||
fi
|
||||
echo "Extracted bundle contents (top-level):"
|
||||
ls -la suwayomi-extracted/
|
||||
|
||||
- name: Stage Suwayomi bundle
|
||||
shell: bash
|
||||
@@ -103,17 +104,15 @@ jobs:
|
||||
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||
if [ -z "$JAVA" ]; then
|
||||
echo "ERROR: jre/bin/java.exe not found. Bundle contents:"
|
||||
echo "ERROR: jre/bin/java.exe not found"
|
||||
find suwayomi-extracted -type f | head -50
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$JAR" ]; then
|
||||
echo "ERROR: Suwayomi-Server.jar not found. Bundle contents:"
|
||||
echo "ERROR: Suwayomi-Server.jar not found"
|
||||
find suwayomi-extracted -type f | head -50
|
||||
exit 1
|
||||
fi
|
||||
echo "Found java: $JAVA"
|
||||
echo "Found jar: $JAR"
|
||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Validate staging
|
||||
@@ -129,19 +128,35 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||
echo "tauri.conf.json patched:"
|
||||
cat src-tauri/tauri.conf.json
|
||||
|
||||
- name: Build Tauri app (Windows x64)
|
||||
- name: Delete existing draft release if present
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
echo "Deleting existing draft release $RELEASE_ID"
|
||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID"
|
||||
# Also delete the tag so tauri-action can recreate it
|
||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||
echo "Deleted draft release and tag"
|
||||
else
|
||||
echo "No existing draft release found"
|
||||
fi
|
||||
|
||||
- name: Build Tauri app + create draft release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
tagName: v${{ github.event.inputs.version }}
|
||||
releaseName: Moku v${{ github.event.inputs.version }}
|
||||
releaseBody: |
|
||||
Windows installer for Moku v${{ github.event.inputs.version }}.
|
||||
Download the `.exe` file below to install or update.
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||
|
||||
- name: Upload Windows installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-windows-x64
|
||||
path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
retention-days: 7
|
||||
|
||||
@@ -37,5 +37,7 @@ src-tauri/gen/
|
||||
# --- Flatpak build artifacts ---
|
||||
build-dir/
|
||||
repo/
|
||||
dist/
|
||||
packaging/frontend-dist.tar.gz
|
||||
*.flatpak
|
||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=moku
|
||||
pkgver=0.4.0
|
||||
pkgver=0.5.0
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,46 +1,113 @@
|
||||
<div align="center">
|
||||
<img src="src/assets/moku-icon-rounded.svg" width="96" />
|
||||
<h1>Moku</h1>
|
||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and Svelte.</p>
|
||||
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||
[](./LICENSE)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
||||
<img src="docs/screenshots/Moku-Discover.png" width="49%" alt="Discover" />
|
||||
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
||||
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots">View all screenshots →</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
## Features
|
||||
|
||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
||||
|
||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||
- **Auto-updates** — in-app update checker with silent background notifications
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
**Nix (recommended)**
|
||||
### Flatpak (Linux, recommended)
|
||||
|
||||
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||
|
||||
```bash
|
||||
nix run github:Youwes09/moku
|
||||
flatpak install moku.flatpak
|
||||
flatpak run dev.moku.app
|
||||
```
|
||||
|
||||
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||
|
||||
### Nix
|
||||
|
||||
```bash
|
||||
nix run github:Youwes09/Moku
|
||||
```
|
||||
|
||||
Add to your flake:
|
||||
|
||||
```nix
|
||||
inputs.moku.url = "github:Youwes09/moku";
|
||||
inputs.moku.url = "github:Youwes09/Moku";
|
||||
```
|
||||
|
||||
**From source**
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Youwes09/moku
|
||||
cd moku
|
||||
nix build
|
||||
./result/bin/moku
|
||||
```
|
||||
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||
|
||||
### macOS
|
||||
|
||||
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||
|
||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||
> ```bash
|
||||
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||
|
||||
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Youwes09/Moku
|
||||
cd Moku
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
Or with Nix:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
pnpm install
|
||||
@@ -54,12 +121,20 @@ pnpm tauri:dev
|
||||
| | |
|
||||
|---|---|
|
||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||
| [Svelte](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
Questions, feedback, or just want to hang out — join the Discord.
|
||||
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||
|
||||
@@ -1,104 +1,43 @@
|
||||
Todo:
|
||||
3. Explore Manga Upscaler & Other Image Processing
|
||||
4. Font Weird on Flatpak, Investigate and Fix
|
||||
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
|
||||
Major Revisions:
|
||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||
|
||||
Minor Revisions:
|
||||
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
||||
|
||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||
- Adjustment in Settings for Theme Editor:
|
||||
- Patch Color-Picker to Work Properly
|
||||
- Moku Discord RPC
|
||||
- Write a better library for Discord RPC & Tauri
|
||||
- Integrate Download Directory Changes (Settings)
|
||||
|
||||
Priority Bugs:
|
||||
- Cache ALL Cover Pictures & Details for Manga in Library
|
||||
- Fix Library Build not Updating
|
||||
- Check Auth System (Only Supports Basic-Auth)
|
||||
|
||||
|
||||
Bugs:
|
||||
|
||||
- Add Back after Search & Clear on Search
|
||||
- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
|
||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
||||
General/Misc Bugs:
|
||||
- Fix Highlightable Elements
|
||||
- Investigate "egl:failed to create dri2 screen"
|
||||
- Check Fonts/Design on Flatpak
|
||||
- Fix Delete-All Crash (Deletes All but Cripples App)
|
||||
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
||||
|
||||
|
||||
- Fix Infinite Scroll Hitting Button Non-reactive to Chapter State, hence Resulting in Error. (Doesn't work on single or double digit, but works on select chapters?) (doesnt work 1 - 2 qnd 51 - 52?) Cause unknow
|
||||
- - Reader appears to be adding integers? Marks chapters incorrectly, need to stablize and patch. User is able to
|
||||
skip chapters, etc
|
||||
- Mark as Read no longer working on select chapters, choose more robust methodology.
|
||||
- Reset to top when user clicks next chapter in reader.
|
||||
In-Progress:
|
||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||
|
||||
- Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
|
||||
|
||||
- Fix Downloaded in Library (Tags Broken) & All
|
||||
- Using Delete All Crashes App (But Works)
|
||||
- Fix Folder Display in Library
|
||||
- Add Version Tags (To Find Version)
|
||||
- Sidebar Icon Highlighted
|
||||
- Introduce Deduplication into Library & Search
|
||||
|
||||
|
||||
Features:
|
||||
- Add PDF Textbook Support
|
||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
||||
- Migration Features
|
||||
- Multi-Page Long Screenshot
|
||||
- Add Consumet Api (Anime & Light Novel Support)
|
||||
|
||||
|
||||
Big Revisions:
|
||||
0. Expand into fully-fledged reader, with modular manga support
|
||||
1. Anime & Novel Support
|
||||
2. Tracker Support
|
||||
3. Cloudflare Bypass Enable Support
|
||||
4. macOS Support (feasible)
|
||||
|
||||
|
||||
|
||||
Testing:
|
||||
|
||||
- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
|
||||
- Fix the Mark as Read (Glitched)
|
||||
|
||||
|
||||
Completed:
|
||||
8. Fix Polling on Download Manager (Instantanous Response)
|
||||
19. Debounce Time on Reader to improve lag (Toggle Setting)
|
||||
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
|
||||
17. Change Library Text change to "No manga saved to library, browse sources to add some."
|
||||
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
|
||||
7. Fix Scaling (100 = 125% and so forth)
|
||||
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
|
||||
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
|
||||
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
|
||||
11. Reader & UI needs download and other Notifications
|
||||
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
|
||||
- Add Refresh Details on Series Details.
|
||||
- Patch GenreDrill & Integrate into Explore Folder
|
||||
18. Disable NSFW Extensions option in settings
|
||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
||||
- Remove Series Detail Mark Read & Unread
|
||||
20. Expand History (Total Time Read, etc)
|
||||
12. Delete all Downloads should also cancel all download queues
|
||||
13. Cancel Download along with Queue & Download Timeout Feature
|
||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
||||
- Extensions Page no Longer Loading efficiently
|
||||
- Map out MangaPreview tags to GenreDrill
|
||||
- GenreDrill & GenreFilter pages do not populate completely.
|
||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
||||
- Clean up Migrate Model to be more initutive
|
||||
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
||||
5. Lock reader on valid chapters to avoid bugs, etc.
|
||||
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
||||
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
||||
- Properly Kill Tachidesk-Server
|
||||
- Fix scaling on splash screen
|
||||
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
|
||||
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
|
||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Important Commands:
|
||||
cd ~/Projects/Manga/Moku
|
||||
pnpm build
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
||||
|
||||
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
||||
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
||||
3. flatpak build-bundle repo moku.flatpak dev.moku.app
|
||||
- Fix TitleBar not Appearing on Windows in Fullscreen (Locks in User)
|
||||
- Integrate Download Directory Changes (Settings)
|
||||
- Fix Source Allow in Content (Doesn't even work)
|
||||
@@ -181,7 +181,7 @@ modules:
|
||||
path: .
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: c78a3f002f898011c4e70e1af781b37dac0fd995b5623170256d88339c90ca74
|
||||
sha256: 43b7274bdab884aacbc3dad6f0f7c043d8e3d82b7bf7398e1df9f516ed553152
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
|
||||
<defs>
|
||||
|
||||
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
|
||||
<stop offset="0%" stop-color="#52b888"/>
|
||||
<stop offset="100%" stop-color="#1e5840"/>
|
||||
</linearGradient>
|
||||
|
||||
<clipPath id="roundedBounds">
|
||||
<rect width="1280" height="320" rx="18" ry="18"/>
|
||||
</clipPath>
|
||||
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#roundedBounds)">
|
||||
|
||||
<rect width="1280" height="320" fill="#070e09"/>
|
||||
|
||||
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
|
||||
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
|
||||
fill="url(#leafHero)" opacity="0.97">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
|
||||
<!-- Stack text pinned to bottom -->
|
||||
<text
|
||||
x="640" y="300"
|
||||
text-anchor="middle"
|
||||
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
|
||||
font-size="14"
|
||||
letter-spacing="5"
|
||||
fill="#a8c4a8"
|
||||
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 7.1 MiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 914 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 609 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 151 KiB |
@@ -18,7 +18,7 @@
|
||||
|
||||
perSystem = { system, lib, ... }:
|
||||
let
|
||||
version = "0.4.0";
|
||||
version = "0.7.0";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
@@ -71,7 +71,7 @@
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-FsZTHeBS9qQ9KYgiwDX1vam6uJXK8OjLe5U6Jfu33lc=";
|
||||
hash = "sha256-ezlckHhfSIe/Hs30tbiN0a/EuvGxhO5L020aup23Ozg=";
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
@@ -149,7 +149,7 @@ EOF
|
||||
|
||||
bumpScript = pkgs.writeShellApplication {
|
||||
name = "moku-bump";
|
||||
runtimeInputs = with pkgs; [ gnused coreutils git ];
|
||||
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
@@ -160,6 +160,7 @@ EOF
|
||||
"$REPO/src-tauri/Cargo.toml"
|
||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||
"$REPO/flake.nix"
|
||||
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||
echo "Bumped to $VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moku",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,9 +11,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"clsx": "^2.1.1",
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"tauri-plugin-drpc": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
|
||||
@@ -11,6 +11,12 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.0.0
|
||||
version: 2.10.1
|
||||
'@tauri-apps/plugin-os':
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2
|
||||
'@tauri-apps/plugin-shell':
|
||||
specifier: ^2.3.5
|
||||
version: 2.3.5
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
@@ -20,6 +26,9 @@ importers:
|
||||
svelte-spa-router:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.2
|
||||
tauri-plugin-drpc:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(typescript@5.9.3)
|
||||
devDependencies:
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: ^4.0.4
|
||||
@@ -433,6 +442,12 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-os@2.3.2':
|
||||
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
|
||||
|
||||
'@tauri-apps/plugin-shell@2.3.5':
|
||||
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -732,6 +747,11 @@ packages:
|
||||
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tauri-plugin-drpc@1.0.3:
|
||||
resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==}
|
||||
peerDependencies:
|
||||
typescript: ^5.0.0
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
@@ -1026,6 +1046,14 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-os@2.3.2':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-shell@2.3.5':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/pug@2.0.10': {}
|
||||
@@ -1344,6 +1372,10 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
zimmerframe: 1.1.4
|
||||
|
||||
tauri-plugin-drpc@1.0.3(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.4.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -17,11 +17,16 @@ tauri-build = { version = "2.0", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
walkdir = "2"
|
||||
sysinfo = "0.32"
|
||||
dirs = "5"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-drpc = "0.1.6"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/bin/sh
|
||||
# Moku — Suwayomi launcher sidecar for macOS.
|
||||
# Tauri calls this script directly as a sidecar (Contents/MacOS/suwayomi-server-{arch}).
|
||||
# The Suwayomi bundle is placed by Tauri into Contents/Resources/suwayomi-bundle/.
|
||||
set -e
|
||||
|
||||
# Resolve the real directory of this script, following symlinks.
|
||||
SELF="$0"
|
||||
while [ -L "$SELF" ]; do
|
||||
SELF="$(readlink "$SELF")"
|
||||
done
|
||||
DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||
|
||||
# ── Locate the bundle ─────────────────────────────────────────────────────────
|
||||
# Inside .app: sidecar = Contents/MacOS/suwayomi-server-{arch}
|
||||
# bundle = Contents/Resources/suwayomi-bundle/
|
||||
# Dev / flat layout: bundle sits next to the sidecar, or one level up.
|
||||
find_bundle() {
|
||||
local base="$1"
|
||||
for candidate in \
|
||||
"${base}/../Resources/suwayomi-bundle" \
|
||||
"${base}/suwayomi-bundle" \
|
||||
"${base}/../suwayomi-bundle"
|
||||
do
|
||||
# The jar lives at <bundle>/bin/Suwayomi-Server.jar
|
||||
if [ -f "${candidate}/bin/Suwayomi-Server.jar" ]; then
|
||||
# Canonicalise (no readlink -f on older macOS sh, use cd trick)
|
||||
echo "$(cd "$candidate" && pwd)"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
BUNDLE=$(find_bundle "$DIR") || {
|
||||
echo "[sidecar] ERROR: cannot locate suwayomi-bundle relative to $DIR" >&2
|
||||
echo "[sidecar] Tried:" >&2
|
||||
echo " $DIR/../Resources/suwayomi-bundle" >&2
|
||||
echo " $DIR/suwayomi-bundle" >&2
|
||||
echo " $DIR/../suwayomi-bundle" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
JAVA="${BUNDLE}/jre/bin/java"
|
||||
JAR="${BUNDLE}/bin/Suwayomi-Server.jar"
|
||||
|
||||
echo "[sidecar] BUNDLE=$BUNDLE" >&2
|
||||
echo "[sidecar] JAVA=$JAVA" >&2
|
||||
echo "[sidecar] JAR=$JAR" >&2
|
||||
|
||||
if [ ! -x "$JAVA" ]; then
|
||||
echo "[sidecar] ERROR: java not executable at $JAVA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$JAR" ]; then
|
||||
echo "[sidecar] ERROR: jar not found at $JAR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# "$@" will contain the -Dsuwayomi.tachidesk.config.server.rootDir=... flag
|
||||
# prepended by spawn_server in lib.rs, followed by -jar <path>.
|
||||
# We call java directly so all JVM flags reach it properly.
|
||||
exec "$JAVA" \
|
||||
-Djava.awt.headless=true \
|
||||
"$@" \
|
||||
-jar "$JAR"
|
||||
@@ -25,6 +25,19 @@
|
||||
"core:window:allow-outer-size",
|
||||
"core:window:allow-inner-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-scale-factor"
|
||||
"core:window:allow-scale-factor",
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download-and-install",
|
||||
"process:default",
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
"drpc:default",
|
||||
"drpc:allow-is-running",
|
||||
"drpc:allow-spawn-thread",
|
||||
"drpc:allow-destroy-thread",
|
||||
"drpc:allow-set-activity",
|
||||
"drpc:allow-clear-activity"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ use std::io::Write;
|
||||
use sysinfo::Disks;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, WindowEvent};
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -24,6 +26,26 @@ pub enum SpawnError {
|
||||
SpawnFailed(String),
|
||||
}
|
||||
|
||||
// ── Update types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single GitHub release returned to the frontend.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ReleaseInfo {
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub published_at: String,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
/// Progress event emitted during download — matches what the frontend listens for.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct UpdateProgress {
|
||||
downloaded: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
/// Strip the \\?\ extended-length path prefix that Windows adds to long paths.
|
||||
/// Java and many other tools do not accept this prefix and will fail silently.
|
||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
||||
@@ -39,10 +61,14 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||
if !downloads_path.trim().is_empty() {
|
||||
return PathBuf::from(downloads_path);
|
||||
}
|
||||
// Mirror Suwayomi-Server's own default: <data_dir>/Tachidesk/downloads
|
||||
// Windows: %LOCALAPPDATA%\Tachidesk\downloads
|
||||
// macOS: ~/Library/Application Support/Tachidesk/downloads
|
||||
// Linux: $XDG_DATA_HOME/Tachidesk/downloads (~/.local/share/Tachidesk/downloads)
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
||||
base.join("Tachidesk/downloads")
|
||||
base.join("Tachidesk").join("downloads")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -82,14 +108,90 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the resolved default downloads path for the current platform.
|
||||
/// This mirrors resolve_downloads_path("") so the frontend can display it.
|
||||
#[tauri::command]
|
||||
fn get_platform_ui_scale() -> f64 {
|
||||
#[cfg(target_os = "windows")]
|
||||
return 1.0;
|
||||
#[cfg(target_os = "macos")]
|
||||
return 1.0;
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
return 1.5;
|
||||
fn get_default_downloads_path() -> String {
|
||||
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Returns true if the given path exists and is a directory.
|
||||
#[tauri::command]
|
||||
fn check_path_exists(path: String) -> bool {
|
||||
std::path::Path::new(path.trim()).is_dir()
|
||||
}
|
||||
|
||||
/// Creates a directory and all missing parent directories.
|
||||
#[tauri::command]
|
||||
fn create_directory(path: String) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Moves all content from `src` into `dst`, then removes `src`.
|
||||
/// Emits `migrate_progress` events: `{ done, total, current }`.
|
||||
/// Only deletes the source tree after every file is confirmed copied.
|
||||
#[tauri::command]
|
||||
async fn migrate_downloads(
|
||||
app: tauri::AppHandle,
|
||||
src: String,
|
||||
dst: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
use std::fs;
|
||||
|
||||
let src_path = std::path::PathBuf::from(src.trim());
|
||||
let dst_path = std::path::PathBuf::from(dst.trim());
|
||||
|
||||
if !src_path.is_dir() {
|
||||
return Ok(()); // nothing to migrate
|
||||
}
|
||||
|
||||
// Count files first so the frontend can show accurate progress
|
||||
let total: u64 = WalkDir::new(&src_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.count() as u64;
|
||||
|
||||
let _ = app.emit("migrate_progress", serde_json::json!({
|
||||
"done": 0u64, "total": total, "current": ""
|
||||
}));
|
||||
|
||||
let mut done: u64 = 0;
|
||||
|
||||
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
|
||||
let target = dst_path.join(rel);
|
||||
|
||||
if entry.file_type().is_dir() {
|
||||
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||
done += 1;
|
||||
let _ = app.emit("migrate_progress", serde_json::json!({
|
||||
"done": done,
|
||||
"total": total,
|
||||
"current": rel.to_string_lossy()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove source after all files are confirmed copied
|
||||
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the OS/monitor DPI scale factor for the window's current monitor.
|
||||
/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K,
|
||||
/// 1.25–1.5 on Windows displays with OS-level scaling applied.
|
||||
/// The frontend multiplies this by the user's uiZoom preference to get the
|
||||
/// final effective zoom applied to document.documentElement.
|
||||
#[tauri::command]
|
||||
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||
window.scale_factor().unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||
@@ -99,16 +201,39 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/FI", "IMAGENAME eq java*"])
|
||||
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.status();
|
||||
|
||||
// Poll until no java.exe remains (up to ~3 s) so the installer can
|
||||
// overwrite the JRE DLLs without hitting a sharing-violation error.
|
||||
for _ in 0..30 {
|
||||
let still_running = std::process::Command::new("tasklist")
|
||||
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !still_running {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.args(["-f", "tachidesk"])
|
||||
.status();
|
||||
}
|
||||
|
||||
|
||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
@@ -203,9 +328,16 @@ struct ServerInvocation {
|
||||
working_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Locate the `java` / `java.exe` binary inside a bundled JRE directory.
|
||||
///
|
||||
/// Expected layout (Windows and Linux):
|
||||
/// <bundle_dir>/jre/bin/java[.exe]
|
||||
///
|
||||
/// On Windows `strip_unc` is applied so Java doesn't choke on `\\?\` prefixes.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let java = bundle_dir.join("jre").join("bin").join("java.exe");
|
||||
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||
@@ -230,28 +362,35 @@ fn resolve_server_binary(
|
||||
) -> Result<ServerInvocation, SpawnError> {
|
||||
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
||||
|
||||
// ── 1. User-specified binary path ─────────────────────────────────────────
|
||||
// Primary: honour the path as-is (doc-2 behaviour — trust the user).
|
||||
// Fallback: if the path doesn't exist after stripping UNC, log a warning
|
||||
// and continue so the bundled detection still has a chance.
|
||||
if !binary.trim().is_empty() {
|
||||
do_log(log, "[resolve] using user-supplied binary path");
|
||||
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
||||
if path.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: binary.to_string(),
|
||||
bin: path.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: None,
|
||||
working_dir: path.parent().map(|p| p.to_path_buf()),
|
||||
});
|
||||
}
|
||||
// Fallback: path was set but file is missing — warn and keep trying.
|
||||
do_log(log, "[resolve] WARNING: user-supplied path not found, falling through to bundled detection");
|
||||
}
|
||||
|
||||
let resource_dir = match app.path().resource_dir() {
|
||||
Ok(p) => {
|
||||
let stripped = strip_unc(p);
|
||||
// Resolve and UNC-strip resource_dir once; used by all non-macOS branches.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let resource_dir = {
|
||||
let raw = app.path().resource_dir().unwrap_or_default();
|
||||
let stripped = strip_unc(raw);
|
||||
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
|
||||
stripped
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("resource_dir error: {e}");
|
||||
do_log(log, &format!("[resolve] ERROR: {}", msg));
|
||||
return Err(SpawnError::SpawnFailed(msg));
|
||||
}
|
||||
};
|
||||
|
||||
// ── 2. Bundled JRE + JAR (Windows / Linux — specific layout) ─────────────
|
||||
// Primary path from doc-2: binaries/suwayomi-bundle/bin/Suwayomi-Server.jar
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||
@@ -269,34 +408,97 @@ fn resolve_server_binary(
|
||||
do_log(log, "[resolve] both java and jar found — using bundled JRE");
|
||||
return Ok(ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
args: vec![
|
||||
"-jar".to_string(),
|
||||
jar.to_string_lossy().into_owned(),
|
||||
],
|
||||
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||
working_dir: Some(bundle_dir),
|
||||
});
|
||||
} else {
|
||||
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path");
|
||||
}
|
||||
do_log(log, "[resolve] java found but jar MISSING — falling through");
|
||||
}
|
||||
None => {
|
||||
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path");
|
||||
do_log(log, "[resolve] java NOT found in bundle — falling through");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2b. Bundled launcher scripts / native sidecars (Windows / Linux) ──────
|
||||
// Fallback for older bundle layouts that ship a wrapper script instead of a
|
||||
// bare JRE + JAR. Also scans for any *.jar in resource_dir as a last resort.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// Named launcher scripts.
|
||||
let script_candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
|
||||
for name in &script_candidates {
|
||||
let p = resource_dir.join(name);
|
||||
do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
do_log(log, &format!("[resolve] using sidecar: {:?}", p));
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(resource_dir.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generic JRE at resource_dir root + any *.jar alongside it.
|
||||
do_log(log, "[resolve] no named sidecar found, trying generic JRE + any jar in resource_dir");
|
||||
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||
let jar = std::fs::read_dir(&resource_dir)
|
||||
.ok()
|
||||
.and_then(|mut rd| {
|
||||
rd.find(|e| {
|
||||
e.as_ref()
|
||||
.map(|e| e.file_name().to_string_lossy().ends_with(".jar"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
});
|
||||
|
||||
do_log(log, &format!("[resolve] generic jar candidate: {:?}", jar));
|
||||
|
||||
if let Some(jar_path) = jar {
|
||||
do_log(log, &format!("[resolve] using generic JRE java={:?} jar={:?}", java, jar_path));
|
||||
return Ok(ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||
working_dir: Some(resource_dir),
|
||||
});
|
||||
}
|
||||
do_log(log, "[resolve] generic JRE found but no .jar — falling through");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. macOS app bundle — MacOS/ then Resources/ ──────────────────────────
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||
let macos_dir = resource_dir
|
||||
.parent()
|
||||
.map(|p| p.join("MacOS"))
|
||||
.unwrap_or_default();
|
||||
|
||||
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
|
||||
|
||||
// Tauri strips the target triple when installing externalBin sidecars into
|
||||
// Contents/MacOS/, so the binary is "suwayomi-server" at runtime.
|
||||
// Triple-suffixed names are kept as a belt-and-suspenders fallback for
|
||||
// dev / flat layouts.
|
||||
let candidates = [
|
||||
"suwayomi-server",
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
"suwayomi-server-x86_64-apple-darwin",
|
||||
"suwayomi-server",
|
||||
"suwayomi-launcher",
|
||||
"suwayomi-launcher.sh",
|
||||
"tachidesk-server",
|
||||
];
|
||||
|
||||
for search_dir in &[&macos_dir, &resource_dir] {
|
||||
for name in &candidates {
|
||||
let p = resource_dir.join(name);
|
||||
let p = search_dir.join(name);
|
||||
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
do_log(log, &format!("[resolve] using macOS candidate: {:?}", p));
|
||||
do_log(log, &format!("[resolve] using macOS sidecar: {:?}", p));
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
@@ -305,9 +507,20 @@ fn resolve_server_binary(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. PATH fallback ──────────────────────────────────────────────────────
|
||||
// Use `where` on Windows, `which` everywhere else.
|
||||
do_log(log, "[resolve] trying PATH fallback");
|
||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||
#[cfg(target_os = "windows")]
|
||||
let found = std::process::Command::new("where")
|
||||
.arg(name)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let found = std::process::Command::new("which")
|
||||
.arg(name)
|
||||
.output()
|
||||
@@ -415,16 +628,119 @@ fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Update commands ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Fetch the list of all GitHub releases so the frontend can show a version picker.
|
||||
/// Uses tauri-plugin-http so it goes through Tauri's permission system.
|
||||
#[tauri::command]
|
||||
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||
use tauri_plugin_http::reqwest;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Moku")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let resp = client
|
||||
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GitHub API returned {}", resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GhRelease {
|
||||
tag_name: String,
|
||||
name: Option<String>,
|
||||
body: Option<String>,
|
||||
published_at: Option<String>,
|
||||
html_url: String,
|
||||
}
|
||||
|
||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(releases
|
||||
.into_iter()
|
||||
.map(|r| ReleaseInfo {
|
||||
tag_name: r.tag_name.clone(),
|
||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||
body: r.body.unwrap_or_default(),
|
||||
published_at: r.published_at.unwrap_or_default(),
|
||||
html_url: r.html_url,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Download and install the latest update using tauri-plugin-updater.
|
||||
/// Emits `update-progress` events with `{ downloaded, total }` while downloading.
|
||||
/// On Windows the installer runs in passive (silent) mode; the frontend prompts restart.
|
||||
/// On other platforms this command is a no-op — the frontend opens the GitHub page instead.
|
||||
#[tauri::command]
|
||||
#[allow(unused_variables)]
|
||||
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
let updater = app.updater().map_err(|e| e.to_string())?;
|
||||
let update = updater.check().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let Some(update) = update else {
|
||||
return Err("No update available from the updater endpoint.".into());
|
||||
};
|
||||
|
||||
let app_clone = app.clone();
|
||||
update
|
||||
.download_and_install(
|
||||
move |downloaded, total| {
|
||||
let _ = app_clone.emit("update-progress", UpdateProgress { downloaded: downloaded as u64, total });
|
||||
},
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the app after a successful update install.
|
||||
#[tauri::command]
|
||||
fn restart_app(app: tauri::AppHandle) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
|
||||
// ── App entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_drpc::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(ServerState(Mutex::new(None)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_storage_info,
|
||||
get_default_downloads_path,
|
||||
check_path_exists,
|
||||
create_directory,
|
||||
migrate_downloads,
|
||||
spawn_server,
|
||||
kill_server,
|
||||
get_platform_ui_scale,
|
||||
list_releases,
|
||||
download_and_install_update,
|
||||
restart_app,
|
||||
])
|
||||
.setup(|_app| Ok(()))
|
||||
.on_window_event(|window, event| {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.4.0",
|
||||
"version": "0.7.0",
|
||||
"identifier": "dev.moku.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -49,6 +49,10 @@
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
||||
"endpoints": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"targets": ["dmg"],
|
||||
"externalBin": [
|
||||
"binaries/suwayomi-server"
|
||||
],
|
||||
"resources": {
|
||||
"binaries/suwayomi-bundle": "suwayomi-bundle"
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "11.0",
|
||||
"exceptionDomain": "localhost",
|
||||
"frameworks": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": [
|
||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||
"binaries/suwayomi-bundle/jre/**/*"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
||||
"endpoints": [
|
||||
"https://github.com/Youwes09/Moku/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,33 +2,108 @@
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { gql } from "./lib/client";
|
||||
import logoUrl from "./assets/moku-icon-splash.svg";
|
||||
import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
|
||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||
import { store, addToast, setActiveDownloads } from "./store/state.svelte";
|
||||
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
||||
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||
import Layout from "./components/layout/Layout.svelte";
|
||||
import Reader from "./components/reader/Reader.svelte";
|
||||
import Settings from "./components/settings/Settings.svelte";
|
||||
import ThemeEditor from "./components/settings/ThemeEditor.svelte";
|
||||
import TitleBar from "./components/layout/TitleBar.svelte";
|
||||
import Toaster from "./components/layout/Toaster.svelte";
|
||||
import SplashScreen from "./components/layout/SplashScreen.svelte";
|
||||
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
||||
|
||||
const MAX_ATTEMPTS = 60;
|
||||
let themeStyleEl: HTMLStyleElement | null = null;
|
||||
|
||||
let serverProbeOk = $state(!store.settings.autoStartServer);
|
||||
let appReady = $state(!store.settings.autoStartServer);
|
||||
$effect(() => {
|
||||
const themeId = store.settings.theme ?? "dark";
|
||||
const isCustom = themeId.startsWith("custom:");
|
||||
|
||||
if (!isCustom) {
|
||||
themeStyleEl?.remove();
|
||||
themeStyleEl = null;
|
||||
document.documentElement.setAttribute("data-theme", themeId);
|
||||
return;
|
||||
}
|
||||
|
||||
const custom = store.settings.customThemes?.find(t => t.id === themeId);
|
||||
if (!custom) {
|
||||
themeStyleEl?.remove();
|
||||
themeStyleEl = null;
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
return;
|
||||
}
|
||||
|
||||
const vars = Object.entries(custom.tokens)
|
||||
.map(([k, v]) => ` --${k}: ${v};`)
|
||||
.join("\n");
|
||||
const css = `[data-theme="custom"] {\n${vars}\n}`;
|
||||
|
||||
if (!themeStyleEl) {
|
||||
themeStyleEl = document.createElement("style");
|
||||
themeStyleEl.id = "moku-custom-theme";
|
||||
document.head.appendChild(themeStyleEl);
|
||||
}
|
||||
themeStyleEl.textContent = css;
|
||||
document.documentElement.setAttribute("data-theme", "custom");
|
||||
});
|
||||
|
||||
let themeEditorOpen = $state(false);
|
||||
let themeEditorEditId = $state<string | null>(null);
|
||||
|
||||
function openThemeEditor(id?: string | null) {
|
||||
themeEditorEditId = id ?? null;
|
||||
themeEditorOpen = true;
|
||||
}
|
||||
|
||||
function closeThemeEditor() {
|
||||
themeEditorOpen = false;
|
||||
themeEditorEditId = null;
|
||||
}
|
||||
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const win = getCurrentWindow();
|
||||
|
||||
let serverProbeOk = $state(false);
|
||||
let appReady = $state(false);
|
||||
let failed = $state(false);
|
||||
let notConfigured = $state(false);
|
||||
let idle = $state(false);
|
||||
let devSplash = $state(false);
|
||||
let platformScale = $state(1);
|
||||
|
||||
let loginRequired = $state(false);
|
||||
let loginUser = $state(store.settings.serverAuthUser ?? "");
|
||||
let loginPass = $state("");
|
||||
let loginError = $state<string | null>(null);
|
||||
let loginBusy = $state(false);
|
||||
let unsupportedMode = $state(false);
|
||||
|
||||
let platformScale = $state(1.0);
|
||||
let _appliedZoom = -1;
|
||||
let _vhRafId: number | null = null;
|
||||
|
||||
function applyZoom() {
|
||||
const normalized = store.settings.uiScale * platformScale;
|
||||
document.documentElement.style.zoom = `${normalized}%`;
|
||||
document.documentElement.style.setProperty("--ui-scale", String(normalized));
|
||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`);
|
||||
const uiZoom = store.settings.uiZoom ?? 1.0;
|
||||
if (uiZoom === _appliedZoom) return;
|
||||
_appliedZoom = uiZoom;
|
||||
|
||||
const pct = uiZoom * 100;
|
||||
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
|
||||
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
|
||||
document.documentElement.style.zoom = `${pct}%`;
|
||||
|
||||
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
|
||||
_vhRafId = requestAnimationFrame(() => {
|
||||
_vhRafId = null;
|
||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
|
||||
});
|
||||
}
|
||||
|
||||
let prevQueue: DownloadQueueItem[] = [];
|
||||
@@ -57,8 +132,8 @@
|
||||
}
|
||||
|
||||
function resetIdle() {
|
||||
if (idle) return;
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
if (idle) return;
|
||||
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||
if (ms === 0) return;
|
||||
idleTimer = setTimeout(() => idle = true, ms);
|
||||
@@ -74,15 +149,10 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-runs whenever uiScale or platformScale changes.
|
||||
store.settings.uiScale; platformScale;
|
||||
void store.settings.uiZoom;
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
document.documentElement.setAttribute("data-theme", store.settings.theme ?? "dark");
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
@@ -92,14 +162,100 @@
|
||||
return () => clearInterval(pollInterval);
|
||||
});
|
||||
|
||||
async function checkForUpdateSilently() {
|
||||
try {
|
||||
const [currentVersion, releases] = await Promise.all([
|
||||
getVersion(),
|
||||
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
||||
]);
|
||||
|
||||
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
||||
if (!valid.length) return;
|
||||
|
||||
const parse = (tag: string): number[] =>
|
||||
tag.replace(/^v/, "").split(".").map(Number);
|
||||
|
||||
const compare = (a: number[], b: number[]): number => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const latestTag = valid
|
||||
.map(r => r.tag_name)
|
||||
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
||||
.replace(/^v/, "");
|
||||
|
||||
const isNewer = compare(parse(latestTag), parse(currentVersion)) < 0;
|
||||
if (isNewer) {
|
||||
addToast({
|
||||
kind: "info",
|
||||
title: `Update available — v${latestTag}`,
|
||||
body: "Open Settings → About to install.",
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let cancelProbe = false;
|
||||
|
||||
function startProbe() {
|
||||
cancelProbe = false;
|
||||
failed = false;
|
||||
loginRequired = false;
|
||||
let tries = 0;
|
||||
|
||||
async function probe() {
|
||||
if (cancelProbe) return;
|
||||
tries++;
|
||||
const result = await probeServer();
|
||||
if (cancelProbe) return;
|
||||
|
||||
if (result === "ok") {
|
||||
serverProbeOk = true;
|
||||
loginRequired = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === "auth_required") {
|
||||
serverProbeOk = true;
|
||||
loginRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === "unsupported_mode") {
|
||||
serverProbeOk = true;
|
||||
unsupportedMode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
|
||||
setTimeout(probe, 750);
|
||||
}
|
||||
|
||||
setTimeout(probe, 800);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
||||
|
||||
// Fetch the platform scale factor then immediately re-apply zoom.
|
||||
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1);
|
||||
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
|
||||
applyZoom();
|
||||
|
||||
store.isFullscreen = await win.isFullscreen();
|
||||
|
||||
const unlistenResize = await win.onResized(async () => {
|
||||
store.isFullscreen = await win.isFullscreen();
|
||||
});
|
||||
|
||||
const unlistenScale = await win.onScaleChanged(async (event) => {
|
||||
platformScale = event.payload.scaleFactor;
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
if (store.settings.autoStartServer) {
|
||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||
if (err?.kind === "NotConfigured") {
|
||||
@@ -110,30 +266,16 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (!serverProbeOk) {
|
||||
let cancelled = false, tries = 0;
|
||||
async function probe() {
|
||||
if (cancelled) return;
|
||||
tries++;
|
||||
try {
|
||||
const res = await fetch(`${store.settings.serverUrl}/api/graphql`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (res.ok && !cancelled) { serverProbeOk = true; return; }
|
||||
} catch {}
|
||||
if (tries >= MAX_ATTEMPTS && !cancelled) { failed = true; return; }
|
||||
if (!cancelled) setTimeout(probe, 500);
|
||||
}
|
||||
setTimeout(probe, 800);
|
||||
}
|
||||
startProbe();
|
||||
|
||||
type P = { chapterId: number; mangaId: number; progress: number }[];
|
||||
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelProbe = true;
|
||||
unlistenResize();
|
||||
unlistenScale();
|
||||
destroyRpc();
|
||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
@@ -142,28 +284,156 @@
|
||||
};
|
||||
});
|
||||
|
||||
function handleRetry() { failed = false; notConfigured = false; serverProbeOk = false; }
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
const timer = setTimeout(checkForUpdateSilently, 5_000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (store.settings.discordRpc) {
|
||||
initRpc();
|
||||
} else {
|
||||
clearReading();
|
||||
destroyRpc();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!store.activeChapter) {
|
||||
if (store.settings.discordRpc) setIdle();
|
||||
}
|
||||
});
|
||||
|
||||
function handleZoomKey(e: KeyboardEvent) {
|
||||
if (!e.ctrlKey) return;
|
||||
if (e.key === "=" || e.key === "+") {
|
||||
e.preventDefault();
|
||||
store.settings.uiZoom = Math.min(2.0, Math.round(((store.settings.uiZoom ?? 1.0) + 0.1) * 10) / 10);
|
||||
} else if (e.key === "-") {
|
||||
e.preventDefault();
|
||||
store.settings.uiZoom = Math.max(0.5, Math.round(((store.settings.uiZoom ?? 1.0) - 0.1) * 10) / 10);
|
||||
} else if (e.key === "0") {
|
||||
e.preventDefault();
|
||||
store.settings.uiZoom = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
window.addEventListener("keydown", handleZoomKey);
|
||||
return () => window.removeEventListener("keydown", handleZoomKey);
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
if (!loginUser.trim() || !loginPass.trim()) {
|
||||
loginError = "Username and password are required";
|
||||
return;
|
||||
}
|
||||
loginBusy = true;
|
||||
loginError = null;
|
||||
try {
|
||||
await loginBasic(loginUser.trim(), loginPass.trim());
|
||||
loginRequired = false;
|
||||
loginPass = "";
|
||||
loginError = null;
|
||||
appReady = true;
|
||||
} catch (e: any) {
|
||||
loginError = e?.message ?? "Login failed";
|
||||
} finally {
|
||||
loginBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
failed = false;
|
||||
notConfigured = false;
|
||||
serverProbeOk = false;
|
||||
loginRequired = false;
|
||||
unsupportedMode = false;
|
||||
startProbe();
|
||||
}
|
||||
|
||||
function handleBypass() {
|
||||
cancelProbe = true;
|
||||
serverProbeOk = true;
|
||||
loginRequired = false;
|
||||
unsupportedMode = false;
|
||||
appReady = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if devSplash}
|
||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||
{:else if !appReady}
|
||||
{:else if !appReady && !loginRequired && !unsupportedMode}
|
||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
||||
showCards={store.settings.splashCards ?? true}
|
||||
onReady={() => appReady = true}
|
||||
onRetry={handleRetry} />
|
||||
onReady={() => { appReady = true; }}
|
||||
onRetry={handleRetry}
|
||||
onBypass={handleBypass} />
|
||||
{:else if unsupportedMode}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card">
|
||||
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||
<p class="auth-title">moku</p>
|
||||
<span class="auth-mode-badge auth-mode-badge--warn">{
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth"
|
||||
}</span>
|
||||
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||
<p class="auth-body">
|
||||
<strong>{
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode"
|
||||
}</strong> is not supported. Switch your server to <strong>Basic Auth</strong> and update Settings → Security.
|
||||
</p>
|
||||
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Continue anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card">
|
||||
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||
<p class="auth-title">moku</p>
|
||||
<span class="auth-mode-badge">Basic Auth</span>
|
||||
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||
{#if loginError}
|
||||
<p class="auth-error">{loginError}</p>
|
||||
{/if}
|
||||
<div class="auth-fields">
|
||||
<input class="auth-input" type="text" placeholder="Username"
|
||||
bind:value={loginUser} disabled={loginBusy} autocomplete="username"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
<input class="auth-input" type="password" placeholder="Password"
|
||||
bind:value={loginPass} disabled={loginBusy} autocomplete="current-password"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
</div>
|
||||
<button class="auth-btn" onclick={handleLogin}
|
||||
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}>
|
||||
{loginBusy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="root">
|
||||
<div id="app-shell" class="root">
|
||||
{#if idle && !store.activeChapter}
|
||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => idle = false, 340)} />
|
||||
onDismiss={() => { idle = false; resetIdle(); }} />
|
||||
{/if}
|
||||
{#if !store.activeChapter}<TitleBar />{/if}
|
||||
<div class="content">
|
||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||
</div>
|
||||
{#if store.settingsOpen}<Settings />{/if}
|
||||
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
||||
{#if themeEditorOpen}
|
||||
<ThemeEditor
|
||||
bind:editingId={themeEditorEditId}
|
||||
onClose={closeThemeEditor}
|
||||
/>
|
||||
{/if}
|
||||
<MangaPreview />
|
||||
<Toaster />
|
||||
</div>
|
||||
@@ -172,4 +442,27 @@
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
|
||||
/* Auth overlay — floats above the SplashScreen */
|
||||
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; }
|
||||
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
|
||||
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
|
||||
.auth-mode-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-full); padding: 2px 10px; }
|
||||
.auth-mode-badge--warn { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
||||
.auth-host { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: -4px 0 0; }
|
||||
.auth-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); margin: 0; }
|
||||
.auth-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.auth-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin: 0; width: 100%; box-sizing: border-box; }
|
||||
.auth-fields { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; }
|
||||
.auth-input { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); outline: none; box-sizing: border-box; transition: border-color var(--t-base), box-shadow var(--t-base); font-family: inherit; }
|
||||
.auth-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
.auth-input:disabled { opacity: 0.5; }
|
||||
.auth-btn { width: 100%; padding: 9px; border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-sm); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); }
|
||||
.auth-btn:hover:not(:disabled) { opacity: 0.85; }
|
||||
.auth-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.auth-btn--ghost { background: none; border-color: transparent; color: var(--text-faint); font-size: var(--text-xs); padding: 4px; }
|
||||
.auth-btn--ghost:hover:not(:disabled) { color: var(--text-muted); opacity: 1; }
|
||||
</style>
|
||||
@@ -4,12 +4,13 @@
|
||||
import Home from "../pages/Home.svelte";
|
||||
import Library from "../pages/Library.svelte";
|
||||
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
||||
import History from "../pages/History.svelte";
|
||||
import RecentActivity from "./RecentActivity.svelte";
|
||||
import Search from "../pages/Search.svelte";
|
||||
import Discover from "../pages/Discover.svelte";
|
||||
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
|
||||
import Downloads from "../pages/Downloads.svelte";
|
||||
import Extensions from "../pages/Extensions.svelte";
|
||||
import Tracking from "../pages/Tracking.svelte";
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
@@ -24,7 +25,7 @@
|
||||
{:else if store.navPage === "search"}
|
||||
<Search />
|
||||
{:else if store.navPage === "history"}
|
||||
<History />
|
||||
<RecentActivity />
|
||||
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
|
||||
<GenreDrillPage />
|
||||
{:else if store.navPage === "explore" || store.navPage === "sources"}
|
||||
@@ -33,6 +34,8 @@
|
||||
<Downloads />
|
||||
{:else if store.navPage === "extensions"}
|
||||
<Extensions />
|
||||
{:else if store.navPage === "tracking"}
|
||||
<Tracking />
|
||||
{:else}
|
||||
<Home />
|
||||
{/if}
|
||||
|
||||
@@ -79,11 +79,11 @@
|
||||
}
|
||||
|
||||
const filtered = $derived(search.trim()
|
||||
? store..filter((e) =>
|
||||
? store.history.filter((e) =>
|
||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
||||
e.chapterName.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: store.);
|
||||
: store.history);
|
||||
|
||||
const sessions = $derived(buildSessions(filtered));
|
||||
|
||||
@@ -97,10 +97,16 @@
|
||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||
});
|
||||
|
||||
// Resume: navigate to the manga's SeriesDetail (which will pick up from
|
||||
// activeChapterList once chapters load). We can't hold a stale chapter list
|
||||
// here — SeriesDetail fetches fresh chapters itself.
|
||||
function resume(session: Session) {
|
||||
const ch = store..find((c) => c.id === session.latestChapterId);
|
||||
if (ch && store..length > 0) openReader(ch, );
|
||||
else setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||
setActiveManga({
|
||||
id: session.mangaId,
|
||||
title: session.mangaTitle,
|
||||
thumbnailUrl: session.thumbnailUrl,
|
||||
inLibrary: false,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
@@ -111,17 +117,17 @@
|
||||
|
||||
<div class="root">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="header">
|
||||
<span class="heading">History</span>
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search store.…" bind:value={search} />
|
||||
<input class="search" placeholder="Search history…" bind:value={search} />
|
||||
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
|
||||
</div>
|
||||
{#if store..length > 0}
|
||||
{#if store.history.length > 0}
|
||||
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
|
||||
title={confirmClear ? "Click again to confirm" : "Clear store. feed"}>
|
||||
title={confirmClear ? "Click again to confirm" : "Clear history"}>
|
||||
<Trash size={14} weight="light" />
|
||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
||||
</button>
|
||||
@@ -129,44 +135,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if store..totalChaptersRead > 0}
|
||||
{#if store.readingStats.totalChaptersRead > 0}
|
||||
<div class="stats-bar">
|
||||
<div class="stat-group">
|
||||
<Fire size={13} weight="fill" class="stat-fire" />
|
||||
<span class="stat-val accent">{store..currentStreakDays}</span>
|
||||
<span class="stat-val accent">{store.readingStats.currentStreakDays}</span>
|
||||
<span class="stat-label">day streak</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
|
||||
<span class="stat-val">{store..totalChaptersRead}</span>
|
||||
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
|
||||
<span class="stat-label">chapters</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<Clock size={13} weight="light" class="stat-icon-neutral" />
|
||||
<span class="stat-val">{formatReadTime(store..totalMinutesRead)}</span>
|
||||
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
|
||||
<span class="stat-label">read time</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
|
||||
<span class="stat-val">{store..totalMangaRead}</span>
|
||||
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
|
||||
<span class="stat-label">series</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<span class="stat-val muted">{store..longestStreakDays}d</span>
|
||||
<span class="stat-val muted">{store.readingStats.longestStreakDays}d</span>
|
||||
<span class="stat-label">best streak</span>
|
||||
</div>
|
||||
<span class="stats-note">Stats are preserved when you clear the feed</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if store..length === 0}
|
||||
{#if store.history.length === 0}
|
||||
<div class="empty">
|
||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
||||
<p class="empty-text">No reading store.</p>
|
||||
<p class="empty-text">No reading history yet</p>
|
||||
<p class="empty-hint">Chapters you read will appear here</p>
|
||||
</div>
|
||||
{:else if sessions.length === 0}
|
||||
@@ -223,16 +229,16 @@
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
.page-header {
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 26px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.search-clear { position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix } from "phosphor-svelte";
|
||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
|
||||
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
||||
import type { NavPage } from "../../store/state.svelte";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ id: "explore", label: "Discover", icon: Compass },
|
||||
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
||||
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
||||
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
|
||||
];
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
@@ -49,19 +50,20 @@
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; }
|
||||
.logo { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
|
||||
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; }
|
||||
.logo { width: 80px; height: 80px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
|
||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||
.logo:active { transform: scale(0.92); }
|
||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
|
||||
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); }
|
||||
.tab { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
|
||||
.nav { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
.tab { width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
|
||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.bottom { display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
||||
.bottom { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
||||
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
||||
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
|
||||
@@ -13,18 +13,47 @@
|
||||
showFps?: boolean;
|
||||
onReady?: () => void;
|
||||
onRetry?: () => void;
|
||||
onBypass?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
|
||||
showCards = true, showFps = false, onReady, onRetry, onDismiss }: Props = $props();
|
||||
showCards = true, showFps = false, onReady, onRetry, onBypass, onDismiss }: Props = $props();
|
||||
|
||||
const lockEnabled = $derived(
|
||||
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
|
||||
);
|
||||
|
||||
let pinEntry = $state("");
|
||||
let pinShake = $state(false);
|
||||
let pinUnlocked = $state(false);
|
||||
let pinVisible = $state(false);
|
||||
|
||||
function submitPin() {
|
||||
if (pinEntry === store.settings.appLockPin) {
|
||||
pinUnlocked = true;
|
||||
pinEntry = "";
|
||||
if (mode === "idle") triggerExit(onDismiss);
|
||||
} else {
|
||||
pinShake = true;
|
||||
pinEntry = "";
|
||||
setTimeout(() => pinShake = false, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function onPinKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") { submitPin(); return; }
|
||||
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
|
||||
if (/^\d$/.test(e.key)) {
|
||||
pinEntry = (pinEntry + e.key).slice(0, 8);
|
||||
if (pinEntry.length >= (store.settings.appLockPin?.length ?? 4)) submitPin();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() { onRetry?.(); }
|
||||
function handleBypass() { onBypass?.(); }
|
||||
|
||||
const EXIT_MS = 320;
|
||||
// Server typically takes 8-20s to boot. We animate the ring through three
|
||||
// phases so it always feels like something is happening:
|
||||
// 0 → 0.75 over ~12s (eased crawl while server starts)
|
||||
// 0.75 → 0.92 over ~8s (slow down near the end, implying "almost there")
|
||||
// jumps to 1.0 the moment the probe succeeds
|
||||
const PHASE1_TARGET = 0.85;
|
||||
const PHASE1_MS = 3000;
|
||||
const PHASE2_TARGET = 0.95;
|
||||
@@ -44,7 +73,6 @@
|
||||
setTimeout(() => cb?.(), EXIT_MS);
|
||||
}
|
||||
|
||||
// Animate ring progress with easing so it never stalls visually
|
||||
let animFrame: number;
|
||||
let animStart: number | null = null;
|
||||
let animPhase = 1;
|
||||
@@ -56,7 +84,6 @@
|
||||
|
||||
if (animPhase === 1) {
|
||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
||||
// ease-out cubic so it starts fast and slows down
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
ringProg = 0.025 + eased * (PHASE1_TARGET - 0.025);
|
||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
||||
@@ -64,7 +91,6 @@
|
||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
||||
const eased = 1 - Math.pow(1 - t, 4);
|
||||
ringProg = PHASE1_TARGET + eased * (PHASE2_TARGET - PHASE1_TARGET);
|
||||
// Phase 2 never completes on its own — only ringFull triggers completion
|
||||
}
|
||||
|
||||
animFrame = requestAnimationFrame(animateRing);
|
||||
@@ -81,8 +107,12 @@
|
||||
if (ringFull) {
|
||||
cancelAnimationFrame(animFrame);
|
||||
ringProg = 1;
|
||||
if (lockEnabled && !pinUnlocked) {
|
||||
setTimeout(() => { pinVisible = true; }, 400);
|
||||
} else {
|
||||
setTimeout(() => triggerExit(onReady), 650);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const dotsInterval = setInterval(() => {
|
||||
@@ -91,6 +121,9 @@
|
||||
|
||||
onMount(() => {
|
||||
if (mode === "idle" && onDismiss) {
|
||||
if (lockEnabled) {
|
||||
return () => clearInterval(dotsInterval);
|
||||
}
|
||||
const handler = () => triggerExit(onDismiss);
|
||||
const t = setTimeout(() => {
|
||||
window.addEventListener("keydown", handler, { once: true });
|
||||
@@ -214,7 +247,7 @@
|
||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
||||
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
|
||||
const sw = stamps[i].width, sh = stamps[i].height;
|
||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
||||
}
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1;
|
||||
@@ -271,6 +304,21 @@
|
||||
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const needsPin =
|
||||
(mode === "idle" && lockEnabled) ||
|
||||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
|
||||
if (!needsPin) return;
|
||||
window.addEventListener("keydown", onPinKey);
|
||||
return () => window.removeEventListener("keydown", onPinKey);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (pinUnlocked && mode !== "idle") {
|
||||
triggerExit(onReady);
|
||||
}
|
||||
});
|
||||
|
||||
const ringR = $derived(70);
|
||||
const ringPad = $derived(12);
|
||||
const ringSize = $derived((ringR + ringPad) * 2);
|
||||
@@ -281,7 +329,7 @@
|
||||
const ringLeft = $derived(-((ringSize - 140) / 2));
|
||||
</script>
|
||||
|
||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' ? 'pointer' : 'default'}">
|
||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
|
||||
{#if showCards}
|
||||
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
||||
{#if showFps}
|
||||
@@ -289,7 +337,23 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if mode === "idle"}
|
||||
{#if mode === "idle" && lockEnabled}
|
||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
|
||||
<div style="position:relative;width:96px;height:96px">
|
||||
<div class="logo-glow"></div>
|
||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:96px;height:96px;border-radius:22px;display:block;position:relative" />
|
||||
</div>
|
||||
<div class="pin-block">
|
||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
|
||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if mode === "idle"}
|
||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
||||
<div style="position:relative;width:128px;height:128px;margin-bottom:32px">
|
||||
<div class="logo-glow"></div>
|
||||
@@ -297,37 +361,52 @@
|
||||
</div>
|
||||
<p class="hint">press any key to continue</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div style="position:relative;width:140px;height:140px;margin-bottom:20px;z-index:1">
|
||||
{#if !failed && !notConfigured}
|
||||
<svg width={ringSize} height={ringSize} style="position:absolute;pointer-events:none;top:{ringTop}px;left:{ringLeft}px">
|
||||
<svg width={ringSize} height={ringSize}
|
||||
class="loading-ring"
|
||||
class:ring-hide={lockEnabled && pinVisible}
|
||||
style="position:absolute;pointer-events:none;top:{ringTop}px;left:{ringLeft}px">
|
||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-dasharray="{ringArc} {ringCirc}" transform="rotate(-90 {ringC} {ringC})" style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="{ringArc} {ringCirc}"
|
||||
transform="rotate(-90 {ringC} {ringC})"
|
||||
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
||||
</svg>
|
||||
{/if}
|
||||
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" />
|
||||
</div>
|
||||
<p class="title-label">moku</p>
|
||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
{#if notConfigured}
|
||||
|
||||
<div class="bottom-area" style="z-index:1">
|
||||
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
||||
{#if failed || notConfigured}
|
||||
<div class="error-box">
|
||||
<p class="error-title">Server not configured</p>
|
||||
<p class="error-body">Set the server path in Settings, then retry</p>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="retry-btn" onclick={() => { store.settingsOpen = true; }}>Settings</button>
|
||||
<button class="retry-btn" onclick={onRetry}>Retry</button>
|
||||
<p class="error-label">
|
||||
{failed ? "Could not reach server" : "Server not configured"}
|
||||
</p>
|
||||
<div class="error-actions">
|
||||
<button class="err-btn" onclick={handleRetry}>Retry</button>
|
||||
<button class="err-btn err-btn--primary" onclick={handleBypass}>Enter app</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if failed}
|
||||
<div class="error-box error-box--danger">
|
||||
<p class="error-title" style="color:var(--color-error)">Could not reach Suwayomi</p>
|
||||
<p class="error-body">Make sure tachidesk-server is on your PATH</p>
|
||||
<button class="retry-btn" style="margin-top:8px" onclick={onRetry}>Retry</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p style="font-family:var(--font-ui);font-size:10px;color:var(--text-faint);letter-spacing:0.12em;margin:0;min-width:160px;text-align:center">
|
||||
{ringFull ? "Ready" : `Initializing server${dots}`}
|
||||
</p>
|
||||
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lockEnabled}
|
||||
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
|
||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
|
||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -344,10 +423,28 @@
|
||||
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
||||
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
||||
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
|
||||
.retry-btn { margin-top: 4px; padding: 5px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.08em; }
|
||||
.retry-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 14px 20px; border-radius: var(--radius-lg); background: rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.12); max-width: 260px; text-align: center; backdrop-filter: blur(4px); }
|
||||
.error-box--danger { border-color: rgba(220,50,50,0.5); }
|
||||
.error-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.1em; margin: 0; }
|
||||
.error-body { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.05em; margin: 0; line-height: 1.6; }
|
||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; }
|
||||
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
|
||||
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
|
||||
.error-actions { display: flex; gap: 6px; }
|
||||
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
|
||||
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
|
||||
|
||||
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
|
||||
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
|
||||
.status-slot-hide { opacity: 0; pointer-events: none; }
|
||||
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
||||
.loading-ring { transition: opacity 0.5s ease; }
|
||||
.ring-hide { opacity: 0; }
|
||||
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
|
||||
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
||||
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
|
||||
.pin-dots { display: flex; gap: 12px; align-items: center; }
|
||||
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
|
||||
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
|
||||
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
||||
.pin-shake { animation: pinShake 0.42s ease; }
|
||||
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
const os = platform();
|
||||
const isMac = os === "macos";
|
||||
const isWindows = os === "windows";
|
||||
|
||||
let isFullscreen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
isFullscreen = await win.isFullscreen();
|
||||
const unlisten = await win.onResized(async () => {
|
||||
isFullscreen = await win.isFullscreen();
|
||||
});
|
||||
return unlisten;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="bar" data-tauri-drag-region>
|
||||
{#if !isFullscreen}
|
||||
<div class="bar" data-tauri-drag-region>
|
||||
{#if isMac}<div class="mac-spacer"></div>{/if}
|
||||
<span class="title" data-tauri-drag-region>Moku</span>
|
||||
{#if !isMac}
|
||||
<div class="controls">
|
||||
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||
@@ -23,9 +42,44 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isWindows}
|
||||
<!-- On Windows, fullscreen hides the native titlebar — show a hoverable overlay so the user isn't locked in -->
|
||||
<div class="fullscreen-controls">
|
||||
<button onclick={() => win.setFullscreen(false)} title="Exit Fullscreen" aria-label="Exit Fullscreen">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.fullscreen-controls {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.fullscreen-controls:hover { opacity: 1; }
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -38,6 +92,12 @@
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
/* Spacer to clear the native macOS traffic lights (~70px) */
|
||||
.mac-spacer {
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
});
|
||||
|
||||
const icons: Record<Toast["kind"], string> = {
|
||||
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||
success: "M20 6L9 17l-5-5",
|
||||
error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
|
||||
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
||||
};
|
||||
@@ -27,10 +27,15 @@
|
||||
{#if store.toasts.length}
|
||||
<div class="toaster" aria-live="polite">
|
||||
{#each store.toasts as t (t.id)}
|
||||
<div class="toast toast-{t.kind}" role="alert">
|
||||
<button
|
||||
class="toast toast-{t.kind}"
|
||||
role="alert"
|
||||
onclick={() => dismissToast(t.id)}
|
||||
>
|
||||
<div class="accent-bar"></div>
|
||||
<span class="icon">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={icons[t.kind]} />
|
||||
</svg>
|
||||
</span>
|
||||
@@ -38,54 +43,102 @@
|
||||
<p class="title">{t.title}</p>
|
||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||
</div>
|
||||
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toaster {
|
||||
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
|
||||
z-index: 9999; display: flex; flex-direction: column;
|
||||
gap: var(--sp-2); pointer-events: none; max-width: 320px;
|
||||
position: fixed;
|
||||
bottom: var(--sp-5);
|
||||
right: var(--sp-5);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 10px var(--sp-3) 10px 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
||||
pointer-events: all; min-width: 220px;
|
||||
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
||||
border: 1px solid var(--border-dim);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
|
||||
pointer-events: all;
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
||||
|
||||
.toast:hover { opacity: 0.85; transform: translateX(-2px); }
|
||||
.toast:active { transform: translateX(0) scale(0.98); }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(16px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
.toast-success { border-color: var(--accent-dim); }
|
||||
|
||||
.accent-bar {
|
||||
width: 3px;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0 2px 2px 0;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.toast-success .accent-bar { background: var(--accent-fg); }
|
||||
.toast-error .accent-bar { background: var(--color-error); }
|
||||
.toast-info .accent-bar { background: var(--text-faint); }
|
||||
.toast-download .accent-bar { background: var(--accent-fg); }
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-success .icon { color: var(--accent-fg); }
|
||||
.toast-error { border-color: var(--color-error); }
|
||||
.toast-error .icon { color: var(--color-error); }
|
||||
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
|
||||
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
|
||||
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
|
||||
.toast-info .icon { color: var(--text-muted); }
|
||||
.toast-download .icon { color: var(--accent-fg); }
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle } from "phosphor-svelte";
|
||||
import { onDestroy } from "svelte";
|
||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
||||
import { store, addFolder, assignMangaToFolder, setPreviewManga } from "../../store/state.svelte";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
|
||||
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
||||
import type { Manga, Source, Category } from "../../lib/types";
|
||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
||||
const GRID_LIMIT = 60; // max rendered per tab
|
||||
const LOCAL_THRESHOLD = 20; // fan out to sources if local results below this
|
||||
const CONCURRENCY = 4; // parallel source requests — kept conservative to not saturate connections
|
||||
const BATCH_INTERVAL = 400; // ms between DOM updates during background source fan-out
|
||||
const GRID_LIMIT = 200;
|
||||
const CONCURRENCY = 6;
|
||||
const PAGES_INIT = 3; // pages per source on All tab
|
||||
const PAGES_GENRE = 2; // pages per source on genre tabs
|
||||
|
||||
const EXPLORE_ALL_MANGA = `
|
||||
query ExploreAllManga {
|
||||
@@ -27,46 +27,55 @@
|
||||
`;
|
||||
const MANGAS_BY_GENRE = `
|
||||
query MangasByGenre($genre: String!, $first: Int) {
|
||||
mangas(
|
||||
filter: { genre: { includesInsensitive: $genre } }
|
||||
first: $first orderBy: IN_LIBRARY_AT orderByType: DESC
|
||||
) { nodes { id title thumbnailUrl inLibrary genre status source { id displayName } } }
|
||||
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
let allManga: Manga[] = $state([]); // local library — loaded once, never triggers lag
|
||||
let allSources: Source[] = $state([]); // all deduped sources — loaded once
|
||||
function dKey(srcId: string, type: string, genre: string, page: number) {
|
||||
return `${srcId}|${type}|${genre}:p${page}`;
|
||||
}
|
||||
|
||||
// ── Local component state ─────────────────────────────────────────────────
|
||||
let allSources: Source[] = $state([]);
|
||||
let loadingLib = $state(true);
|
||||
let loadError = $state(false);
|
||||
|
||||
// Per-genre result map. Keyed by genre string.
|
||||
// "All" key → local library deduped by title
|
||||
// Each tab key → local + background source results, deduped id+title
|
||||
let genreResults = $state(new Map<string, Manga[]>());
|
||||
let genreLoading = $state(false); // true only during the initial local fetch for a new tab
|
||||
let currentGenre = $state("All");
|
||||
let genreAbort: AbortController | null = null;
|
||||
let genreResults = $state(new Map<string, Manga[]>());
|
||||
let genreLoading = $state(false);
|
||||
let refreshing = $state(false);
|
||||
|
||||
// batch timer handle for background source fan-out
|
||||
let batchTimer: ReturnType<typeof setInterval> | null = null;
|
||||
// accumulator: source results collected between batches
|
||||
let batchAccum = new Map<string, Manga[]>(); // genre → pending mangas
|
||||
|
||||
// Context menu
|
||||
let activeCtrl: AbortController | null = null;
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
let isLoading = $state(false);
|
||||
let categories: Category[] = $state([]);
|
||||
let catsLoaded = false;
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────────
|
||||
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
||||
$effect(() => { isLoading = genreLoading || (currentGenre === "All" && loadingLib); });
|
||||
|
||||
// ── Dedup helper — always apply id first then title ───────────────────────────
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function dedup(items: Manga[]): Manga[] {
|
||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
||||
}
|
||||
|
||||
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
|
||||
function filterOut(mangas: Manga[]): Manga[] {
|
||||
return dedup(mangas.filter(m => {
|
||||
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
|
||||
if (shouldHideNsfw(m, store.settings)) return false;
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
function rotatedSources(): Source[] {
|
||||
const lang = store.settings.preferredExtensionLang || "en";
|
||||
const eligible = allSources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
|
||||
const srcs = dedupeSources(eligible, lang);
|
||||
if (!srcs.length) return [];
|
||||
const off = store.discoverSrcOffset % srcs.length;
|
||||
return [...srcs.slice(off), ...srcs.slice(0, off)];
|
||||
}
|
||||
|
||||
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
|
||||
let i = 0;
|
||||
const worker = async () => {
|
||||
@@ -78,201 +87,231 @@
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||
}
|
||||
|
||||
// ── Batched DOM flush ─────────────────────────────────────────────────────────
|
||||
// Source fan-out collects results in batchAccum. A timer fires every BATCH_INTERVAL
|
||||
// ms and flushes them into genreResults in one shot — preventing a Svelte re-render
|
||||
// per-source and keeping the grid smooth.
|
||||
function startBatchFlush() {
|
||||
if (batchTimer) return;
|
||||
batchTimer = setInterval(() => {
|
||||
if (batchAccum.size === 0) return;
|
||||
for (const [genre, incoming] of batchAccum) {
|
||||
const current = genreResults.get(genre) ?? [];
|
||||
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
|
||||
}
|
||||
batchAccum.clear();
|
||||
genreResults = new Map(genreResults);
|
||||
}, BATCH_INTERVAL);
|
||||
}
|
||||
|
||||
function stopBatchFlush() {
|
||||
if (batchTimer) { clearInterval(batchTimer); batchTimer = null; }
|
||||
// Final flush of anything remaining
|
||||
if (batchAccum.size > 0) {
|
||||
for (const [genre, incoming] of batchAccum) {
|
||||
const current = genreResults.get(genre) ?? [];
|
||||
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
|
||||
}
|
||||
batchAccum.clear();
|
||||
// Push results into the reactive grid immediately — no batch delay.
|
||||
function pushToGrid(genre: string, incoming: Manga[]) {
|
||||
const filtered = filterOut(incoming);
|
||||
if (!filtered.length) return;
|
||||
const cur = genreResults.get(genre) ?? [];
|
||||
genreResults.set(genre, dedup([...cur, ...filtered]).slice(0, GRID_LIMIT));
|
||||
genreResults = new Map(genreResults);
|
||||
}
|
||||
}
|
||||
|
||||
// Push source results into the accumulator (never touches the DOM directly)
|
||||
function accumulate(genre: string, mangas: Manga[]) {
|
||||
const existing = batchAccum.get(genre) ?? [];
|
||||
batchAccum.set(genre, [...existing, ...mangas]);
|
||||
}
|
||||
// ── Source fan-out ────────────────────────────────────────────────────────
|
||||
async function fanOut(genre: string, ctrl: AbortController) {
|
||||
const srcs = rotatedSources();
|
||||
if (!srcs.length) return;
|
||||
|
||||
// ── Background source fan-out for a genre ────────────────────────────────────
|
||||
// Runs entirely in the background. Results appear in batches via batchAccum.
|
||||
// Does NOT set genreLoading = true — the local result is already showing.
|
||||
async function fanOutSources(genre: string, ctrl: AbortController) {
|
||||
if (!allSources.length) return;
|
||||
const lang = store.settings.preferredExtensionLang || "en";
|
||||
const srcs = dedupeSources(allSources, lang);
|
||||
|
||||
startBatchFlush();
|
||||
const isAll = genre === "All";
|
||||
const type = isAll ? "POPULAR" : "SEARCH";
|
||||
const query = isAll ? null : genre;
|
||||
const maxPages = isAll ? PAGES_INIT : PAGES_GENRE;
|
||||
|
||||
await runConcurrent(srcs, async src => {
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, [genre]);
|
||||
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: genre }, ctrl.signal
|
||||
).then(d => d.fetchSourceManga),
|
||||
5 * 60 * 1000, // 5-min TTL — results are stable enough to cache
|
||||
).catch(() => null);
|
||||
|
||||
const key = dKey(src.id, type, genre, page);
|
||||
let mangas: Manga[];
|
||||
let hasNextPage = false;
|
||||
|
||||
if (store.discoverCache.has(key)) {
|
||||
// Cache hit — no network call needed
|
||||
mangas = store.discoverCache.get(key)!;
|
||||
} else {
|
||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type, page, query },
|
||||
ctrl.signal
|
||||
).then(d => d.fetchSourceManga).catch(() => null);
|
||||
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
|
||||
// Only accumulate results that actually match the genre (client-side AND check)
|
||||
const matching = result.mangas.filter(m =>
|
||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
||||
|| result.mangas.length <= 5 // source returns few results, trust them
|
||||
);
|
||||
|
||||
accumulate(genre, matching.length > 0 ? matching : result.mangas);
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) stopBatchFlush();
|
||||
mangas = result.mangas;
|
||||
hasNextPage = result.hasNextPage;
|
||||
store.discoverCache.set(key, mangas);
|
||||
}
|
||||
|
||||
// ── Tab switch ───────────────────────────────────────────────────────────────
|
||||
// 1. Show local results immediately (no spinner if already cached)
|
||||
// 2. If local < LOCAL_THRESHOLD, kick off background fan-out silently
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
if (isAll) {
|
||||
pushToGrid("All", mangas);
|
||||
} else {
|
||||
const matching = mangas.filter(m =>
|
||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
||||
);
|
||||
pushToGrid(genre, matching.length ? matching : mangas);
|
||||
}
|
||||
|
||||
// Stop paging early if source is exhausted
|
||||
if (!hasNextPage) return;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
}
|
||||
|
||||
// ── Tab switch ────────────────────────────────────────────────────────────
|
||||
async function switchGenre(genre: string) {
|
||||
if (currentGenre === genre) return;
|
||||
|
||||
// Abort any in-flight fan-out for the previous tab
|
||||
genreAbort?.abort();
|
||||
stopBatchFlush();
|
||||
|
||||
activeCtrl?.abort();
|
||||
currentGenre = genre;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
activeCtrl = ctrl;
|
||||
|
||||
if (genre === "All") {
|
||||
// "All" is just the deduped local library — no network needed
|
||||
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
||||
// Already have results from this session — show instantly, re-fan in background
|
||||
if ((genreResults.get("All") ?? []).length > 0) {
|
||||
genreLoading = false;
|
||||
fanOut("All", ctrl).catch(() => {});
|
||||
return;
|
||||
}
|
||||
genreResults.set("All", []);
|
||||
genreResults = new Map(genreResults);
|
||||
genreLoading = true;
|
||||
await fanOut("All", ctrl);
|
||||
if (!ctrl.signal.aborted) genreLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already have a fully-populated cache for this genre, show it instantly
|
||||
const cached = genreResults.get(genre);
|
||||
if (cached && cached.length >= LOCAL_THRESHOLD) return;
|
||||
// Genre tab: serve cached local results instantly, always fan out too
|
||||
const localKey = `local|${genre}`;
|
||||
if (store.discoverCache.has(localKey)) {
|
||||
genreResults.set(genre, store.discoverCache.get(localKey)!);
|
||||
genreResults = new Map(genreResults);
|
||||
fanOut(genre, ctrl).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch local results (fast — single DB query)
|
||||
genreLoading = true;
|
||||
const ctrl = new AbortController();
|
||||
genreAbort = ctrl;
|
||||
|
||||
try {
|
||||
const localData = await cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal)
|
||||
.then(d => d.mangas.nodes)
|
||||
const d = await gql<{ mangas: { nodes: Manga[] } }>(
|
||||
MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal
|
||||
);
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
const local = dedup(localData);
|
||||
const local = dedup(
|
||||
d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings))
|
||||
);
|
||||
store.discoverCache.set(localKey, local);
|
||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
||||
genreResults = new Map(genreResults);
|
||||
genreLoading = false;
|
||||
|
||||
// If sparse, fan out to sources in the background — no loading state shown
|
||||
if (local.length < LOCAL_THRESHOLD) {
|
||||
fanOutSources(genre, ctrl).catch(() => {}); // fully detached background task
|
||||
}
|
||||
fanOut(genre, ctrl).catch(() => {});
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
if (!ctrl.signal.aborted) genreLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context menu ──────────────────────────────────────────────────────────────
|
||||
// ── Refresh ───────────────────────────────────────────────────────────────
|
||||
async function refresh() {
|
||||
activeCtrl?.abort();
|
||||
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset
|
||||
genreResults = new Map();
|
||||
refreshing = true;
|
||||
genreLoading = true;
|
||||
const genre = currentGenre;
|
||||
currentGenre = "";
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
await switchGenre(genre);
|
||||
refreshing = false;
|
||||
}
|
||||
|
||||
// ── Initial load ──────────────────────────────────────────────────────────
|
||||
function loadAll() {
|
||||
loadingLib = true;
|
||||
loadError = false;
|
||||
|
||||
// Already have a session grid — show it immediately
|
||||
if ((genreResults.get("All") ?? []).length > 0) {
|
||||
loadingLib = false;
|
||||
}
|
||||
|
||||
// Refresh library ID set so newly-added manga get filtered out
|
||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
||||
).then(m => {
|
||||
store.discoverLibraryIds = new Set(
|
||||
dedupeMangaById(m).filter(x => x.inLibrary).map(x => x.id)
|
||||
);
|
||||
}).catch(e => { console.error(e); loadError = true; })
|
||||
.finally(() => { loadingLib = false; });
|
||||
|
||||
// Load sources then kick off All tab fan-out (only if grid is empty)
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then(d => {
|
||||
allSources = d.sources.nodes;
|
||||
if ((currentGenre === "All" || currentGenre === "") &&
|
||||
(genreResults.get("All") ?? []).length === 0) {
|
||||
const ctrl = new AbortController();
|
||||
activeCtrl = ctrl;
|
||||
genreLoading = true;
|
||||
fanOut("All", ctrl).then(() => {
|
||||
if (!ctrl.signal.aborted) genreLoading = false;
|
||||
}).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
onDestroy(() => { activeCtrl?.abort(); });
|
||||
|
||||
loadAll();
|
||||
|
||||
// ── Context menu ──────────────────────────────────────────────────────────
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||
if (!catsLoaded) {
|
||||
catsLoaded = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: BookmarkSimple, disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error),
|
||||
.then(() => {
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
||||
}).catch(console.error),
|
||||
},
|
||||
...(store.settings.folders.length > 0 ? [
|
||||
...(categories.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...store.settings.folders.map(f => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
...categories.map(cat => ({
|
||||
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
||||
icon: Folder,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add", icon: FolderSimplePlus,
|
||||
onClick: () => {
|
||||
onClick: async () => {
|
||||
const n = prompt("Folder name:");
|
||||
if (n?.trim()) { const id = addFolder(n.trim()); assignMangaToFolder(id, m.id); }
|
||||
if (!n?.trim()) return;
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: n.trim() }).catch(console.error);
|
||||
if (res) {
|
||||
const cat = res.createCategory.category;
|
||||
categories = [...categories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Initial load ──────────────────────────────────────────────────────────────
|
||||
// 1. Load local library → populate "All" tab immediately
|
||||
// 2. Load source list in background (needed for genre fan-out, not needed for initial render)
|
||||
function loadAll() {
|
||||
loadingLib = true; loadError = false;
|
||||
const lang = store.settings.preferredExtensionLang || "en";
|
||||
|
||||
// Local library — populates "All" tab
|
||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
||||
).then(m => {
|
||||
allManga = dedupeMangaById(m);
|
||||
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
||||
genreResults = new Map(genreResults);
|
||||
}).catch(e => { console.error(e); loadError = true; })
|
||||
.finally(() => { loadingLib = false; });
|
||||
|
||||
// Source list — loaded silently in background, cached for the session
|
||||
// Not awaited — the grid doesn't depend on this for the initial render
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then(d => dedupeSources(d.sources.nodes, lang)),
|
||||
Infinity, // pin for session — source list is stable
|
||||
).then(srcs => {
|
||||
allSources = srcs;
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
onMount(loadAll);
|
||||
onDestroy(() => {
|
||||
genreAbort?.abort();
|
||||
stopBatchFlush();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ── Source browse passthrough ─────────────────────────────────────────────── -->
|
||||
{#if store.activeSource}
|
||||
<SourceBrowse />
|
||||
{:else}
|
||||
<div class="root">
|
||||
|
||||
<!-- ── Header: page label + genre pill tabs ──────────────────────────────── -->
|
||||
<div class="header">
|
||||
<span class="heading">Discover</span>
|
||||
<div class="tab-strip">
|
||||
@@ -287,13 +326,13 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="refresh-btn" class:spinning={refreshing} onclick={refresh} title="Refresh results" disabled={refreshing}>
|
||||
<ArrowsClockwise size={13} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Body ──────────────────────────────────────────────────────────────── -->
|
||||
<div class="body">
|
||||
|
||||
{#if isLoading}
|
||||
<!-- Skeleton — shown only during first local fetch, never during bg fan-out -->
|
||||
{#if isLoading && visibleGrid.length === 0}
|
||||
<div class="manga-grid">
|
||||
{#each Array(24) as _, i (i)}
|
||||
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
||||
@@ -318,25 +357,20 @@
|
||||
oncontextmenu={(e) => openCtx(e, m)}
|
||||
>
|
||||
<div class="cover-wrap">
|
||||
<img
|
||||
src={thumbUrl(m.thumbnailUrl)} alt={m.title}
|
||||
class="cover" loading="lazy" decoding="async"
|
||||
/>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||
<div class="cover-gradient"></div>
|
||||
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
||||
<div class="card-footer">
|
||||
<p class="card-title">{m.title}</p>
|
||||
{#if m.source?.displayName}
|
||||
<p class="card-source">{m.source.displayName}</p>
|
||||
{/if}
|
||||
{#if m.source?.displayName}<p class="card-source">{m.source.displayName}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -346,117 +380,36 @@
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||
.header {
|
||||
display: flex; align-items: center; gap: var(--sp-4); flex-shrink: 0;
|
||||
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.header { display: flex; align-items: center; gap: var(--sp-4); flex-shrink: 0; padding: var(--sp-3) var(--sp-4) var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); overflow-x: auto; scrollbar-width: none; }
|
||||
.header::-webkit-scrollbar { display: none; }
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Genre pill tabs */
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.genre-tab {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 12px; border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
||||
cursor: pointer; white-space: nowrap;
|
||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.genre-tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||
.genre-tab:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.genre-tab.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
|
||||
/* ── Body ────────────────────────────────────────────────────────────────── */
|
||||
.body {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: var(--sp-4) var(--sp-5) var(--sp-6);
|
||||
/* GPU-accelerated scroll — does NOT promote every card, only the scroll container */
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* ── Grid ────────────────────────────────────────────────────────────────── */
|
||||
.manga-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr));
|
||||
gap: var(--sp-2);
|
||||
align-content: start;
|
||||
/* Isolate the grid from the rest of the layout — prevents full-page reflow on update */
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* ── Card ────────────────────────────────────────────────────────────────── */
|
||||
.manga-card {
|
||||
background: none; border: none; padding: 0; cursor: pointer; text-align: left;
|
||||
/* NO will-change here — only promote on actual hover to avoid 60+ simultaneous GPU layers */
|
||||
}
|
||||
.refresh-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; margin-left: auto; transition: color var(--t-base), background var(--t-base); }
|
||||
.refresh-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
|
||||
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||
.manga-card:hover .card-title { color: #fff; }
|
||||
/* Promote only the hovered card to its own GPU layer */
|
||||
.manga-card:hover { will-change: transform; }
|
||||
|
||||
.cover-wrap {
|
||||
position: relative; aspect-ratio: 2/3; overflow: hidden;
|
||||
border-radius: var(--radius-md); background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
}
|
||||
.cover {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
transition: filter 0.15s ease, transform 0.15s ease;
|
||||
/* will-change removed — only the parent card gets it on hover */
|
||||
}
|
||||
.cover-gradient {
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.lib-badge {
|
||||
position: absolute; top: var(--sp-1); right: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm);
|
||||
}
|
||||
.card-footer {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
padding: var(--sp-2); pointer-events: none;
|
||||
}
|
||||
.card-title {
|
||||
font-size: var(--text-xs); font-weight: var(--weight-medium);
|
||||
color: rgba(255,255,255,0.92); line-height: var(--leading-snug);
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.7);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.card-source {
|
||||
font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45);
|
||||
letter-spacing: var(--tracking-wide); margin-top: 1px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Skeleton ────────────────────────────────────────────────────────────── */
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
||||
.cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
||||
.lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); }
|
||||
.card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
||||
.card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); transition: color var(--t-base); }
|
||||
.card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.card-skeleton { padding: 0; }
|
||||
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
|
||||
/* ── Empty / error ───────────────────────────────────────────────────────── */
|
||||
.empty {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: var(--sp-3); padding: var(--sp-10) var(--sp-6);
|
||||
color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.retry-btn {
|
||||
padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised); color: var(--text-muted); cursor: pointer;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.skeleton { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.85 } }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); padding: var(--sp-10) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
.retry-btn { padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
.refresh-btn.spinning { opacity: 0.5; cursor: default; }
|
||||
.refresh-btn.spinning :global(svg) { animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Downloads</h1>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
|
||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
||||
@@ -89,6 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="status-bar">
|
||||
<div class="status-dot" class:active={isRunning}></div>
|
||||
<span class="status-text">
|
||||
@@ -139,18 +141,20 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div><!-- .content -->
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { padding: var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-5); }
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-actions { display: flex; gap: var(--sp-2); }
|
||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); margin-bottom: var(--sp-4); }
|
||||
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
||||
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { UPDATE_MANGA, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/util";
|
||||
import { settings, activeSource, genreFilter, previewManga, history, addFolder, assignMangaToFolder } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import SourceList from "../sources/SourceList.svelte";
|
||||
import SourceBrowse from "../sources/SourceBrowse.svelte";
|
||||
import GenreDrillPage from "./GenreDrillPage.svelte";
|
||||
|
||||
type ExploreMode = "explore" | "sources";
|
||||
let mode: ExploreMode = "explore";
|
||||
|
||||
const EXPLORE_ALL_MANGA = `
|
||||
query ExploreAllManga {
|
||||
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const MANGAS_BY_GENRE_EXPLORE = `
|
||||
query MangasByGenreExplore($genre: String!, $first: Int) {
|
||||
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||
nodes { id title thumbnailUrl inLibrary genre }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
||||
const ROW_CAP = 25;
|
||||
const GHOST_COUNT = 3;
|
||||
|
||||
let allManga: Manga[] = [];
|
||||
let popularManga: Manga[] = [];
|
||||
let sources: Source[] = [];
|
||||
let genreResultsMap = new Map<string, Manga[]>();
|
||||
let loadingLib = true;
|
||||
let loadingPopular = true;
|
||||
let loadingGenres = false;
|
||||
let loadError = false;
|
||||
let retryCount = 0;
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
||||
let abortCtrl: AbortController | null = null;
|
||||
let fetchedGenresKey = "";
|
||||
|
||||
function frecencyScore(readAt: number, count: number): number {
|
||||
return count / Math.log((Date.now() - readAt) / 3_600_000 + 2);
|
||||
}
|
||||
|
||||
$: frecencyGenres = (() => {
|
||||
const mangaScores = new Map<number, number>();
|
||||
const mangaReadAt = new Map<number, number>();
|
||||
for (const e of $history) {
|
||||
mangaScores.set(e.mangaId, (mangaScores.get(e.mangaId) ?? 0) + 1);
|
||||
if (e.readAt > (mangaReadAt.get(e.mangaId) ?? 0)) mangaReadAt.set(e.mangaId, e.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 g of mangaMap.get(mangaId)?.genre ?? []) genreWeights.set(g, (genreWeights.get(g) ?? 0) + score);
|
||||
}
|
||||
if (genreWeights.size === 0) allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
||||
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
|
||||
return Array.from(genreWeights.entries()).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([g]) => g);
|
||||
})();
|
||||
|
||||
$: continueReading = (() => {
|
||||
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 e of $history) {
|
||||
if (seen.has(e.mangaId)) continue;
|
||||
seen.add(e.mangaId);
|
||||
const manga = mangaMap.get(e.mangaId);
|
||||
if (!manga) continue;
|
||||
result.push({ manga, chapterName: e.chapterName, progress: e.pageNumber > 0 ? Math.min(e.pageNumber / 20, 1) : 0 });
|
||||
if (result.length >= 12) break;
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
$: recommended = allManga.length && frecencyGenres.length ? (() => {
|
||||
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);
|
||||
})() : [];
|
||||
|
||||
$: if (frecencyGenres.length && allManga.length) loadGenreRows();
|
||||
|
||||
async function loadGenreRows() {
|
||||
const key = frecencyGenres.join(",");
|
||||
if (fetchedGenresKey === key) return;
|
||||
fetchedGenresKey = key;
|
||||
loadingGenres = true;
|
||||
abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
const streamMap = new Map<string, Manga[]>();
|
||||
await Promise.allSettled(
|
||||
frecencyGenres.map((genre) =>
|
||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(MANGAS_BY_GENRE_EXPLORE, { genre, first: 25 }, ctrl.signal)
|
||||
.then((d) => d.mangas.nodes)
|
||||
).then((mangas) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
streamMap.set(genre, mangas);
|
||||
genreResultsMap = new Map(streamMap);
|
||||
})
|
||||
)
|
||||
).catch(() => {});
|
||||
if (!ctrl.signal.aborted) loadingGenres = false;
|
||||
}
|
||||
|
||||
$: if (retryCount >= 0) loadData();
|
||||
|
||||
async function loadData() {
|
||||
if (allManga.length > 0 && retryCount === 0) return;
|
||||
loadingLib = true; loadingPopular = true; loadError = false;
|
||||
const preferredLang = $settings.preferredExtensionLang || "en";
|
||||
if (retryCount > 0) { cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.SOURCES); fetchedGenresKey = ""; }
|
||||
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then((d) => d.mangas.nodes)
|
||||
).then((m) => { allManga = m; }).catch((e) => { console.error(e); loadError = true; }).finally(() => loadingLib = false);
|
||||
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES).then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
||||
).then(async (allSources) => {
|
||||
if (!allSources.length) { loadingPopular = false; return; }
|
||||
const top = getTopSources(allSources).slice(0, 2);
|
||||
sources = allSources;
|
||||
cache.get(CACHE_KEYS.POPULAR, () =>
|
||||
Promise.allSettled(top.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "POPULAR", page: 1, query: null })
|
||||
.then((d) => d.fetchSourceManga.mangas)
|
||||
)).then((results) => {
|
||||
const merged: Manga[] = [];
|
||||
for (const r of results) if (r.status === "fulfilled") merged.push(...r.value);
|
||||
return dedupeMangaByTitle(merged).slice(0, 30);
|
||||
})
|
||||
).then((m) => popularManga = m).catch(console.error).finally(() => loadingPopular = false);
|
||||
}).catch((e) => { console.error(e); loadError = true; loadingPopular = false; });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
return [
|
||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error) },
|
||||
...($settings.folders.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...$settings.folders.map((f): MenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
||||
];
|
||||
}
|
||||
|
||||
function rowWheel(e: WheelEvent) {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
const el = e.currentTarget as HTMLDivElement;
|
||||
if (el.scrollLeft <= 0 && el.scrollLeft >= el.scrollWidth - el.clientWidth - 1) return;
|
||||
e.stopPropagation();
|
||||
el.scrollLeft += e.deltaY;
|
||||
}
|
||||
|
||||
onDestroy(() => abortCtrl?.abort());
|
||||
</script>
|
||||
|
||||
{#if $activeSource}
|
||||
<SourceBrowse />
|
||||
{:else if $genreFilter}
|
||||
<GenreDrillPage />
|
||||
{:else}
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="heading">Explore</h1>
|
||||
<div class="tabs">
|
||||
<button class="tab" class:active={mode === "explore"} on:click={() => mode = "explore"}>
|
||||
<Compass size={11} weight="bold" /> Explore
|
||||
</button>
|
||||
<button class="tab" class:active={mode === "sources"} on:click={() => mode = "sources"}>
|
||||
<List size={11} weight="bold" /> Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:{mode === 'explore' ? 'contents' : 'none'}">
|
||||
<div class="body">
|
||||
|
||||
{#if continueReading.length > 0 || loadingLib}
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title"><BookOpen size={11} weight="bold" /> Continue Reading</span>
|
||||
</div>
|
||||
{#if loadingLib}
|
||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
||||
{:else}
|
||||
<div class="row" on:wheel={rowWheel}>
|
||||
{#each continueReading.slice(0, ROW_CAP) as { manga, chapterName, progress }}
|
||||
<button class="card" on:click={() => previewManga.set(manga)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga }; }}>
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="cover" loading="lazy" decoding="async" />
|
||||
{#if manga.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||
{#if progress > 0}<div class="progress-bar"><div class="progress-fill" style="width:{progress * 100}%"></div></div>{/if}
|
||||
</div>
|
||||
<p class="title">{manga.title}</p>
|
||||
{#if chapterName}<p class="subtitle">{chapterName}</p>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if recommended.length > 0 || loadingLib}
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title"><Star size={11} weight="bold" /> Recommended for You</span>
|
||||
</div>
|
||||
{#if loadingLib}
|
||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
||||
{:else}
|
||||
<div class="row" on:wheel={rowWheel}>
|
||||
{#each recommended.slice(0, ROW_CAP) as m}
|
||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
||||
<p class="title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if popularManga.length > 0 || loadingPopular}
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">
|
||||
<Fire size={11} weight="bold" />
|
||||
{sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
||||
</span>
|
||||
</div>
|
||||
{#if loadingPopular}
|
||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
||||
{:else if sources.length === 0}
|
||||
<div class="no-source">No sources installed. Add extensions first.</div>
|
||||
{:else}
|
||||
<div class="row" on:wheel={rowWheel}>
|
||||
{#each popularManga.slice(0, ROW_CAP) as m}
|
||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
||||
<p class="title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each frecencyGenres as genre}
|
||||
{@const items = genreResultsMap.get(genre) ?? []}
|
||||
{@const isLoading = loadingGenres && items.length === 0}
|
||||
{#if isLoading || items.length > 0}
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">{genre}</span>
|
||||
<button class="see-all" on:click={() => genreFilter.set(genre)}>See all <ArrowRight size={11} weight="light" /></button>
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
||||
{:else}
|
||||
<div class="row" on:wheel={rowWheel}>
|
||||
{#each items.slice(0, ROW_CAP) as m}
|
||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
||||
<p class="title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#if items.length >= ROW_CAP}
|
||||
<button class="explore-more-card" on:click={() => genreFilter.set(genre)}>
|
||||
<div class="explore-more-inner">
|
||||
<ArrowRight size={20} weight="light" class="explore-more-icon" />
|
||||
<span class="explore-more-label">Explore more</span>
|
||||
<span class="explore-more-genre">{genre}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if !loadingLib && !loadingPopular && !loadingGenres && continueReading.length === 0 && recommended.length === 0 && popularManga.length === 0 && frecencyGenres.every((g) => !genreResultsMap.get(g)?.length)}
|
||||
<div class="empty">
|
||||
{#if loadError}
|
||||
<span>Could not reach Suwayomi</span>
|
||||
<span class="empty-hint">Make sure the server is running, then try again.</span>
|
||||
<button class="retry-btn" on:click={() => { loadingLib = true; loadingPopular = true; retryCount++; }}>Retry</button>
|
||||
{:else}
|
||||
<span>Nothing to explore yet</span>
|
||||
<span class="empty-hint">Add manga to your library or install sources to get started.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if mode === "sources"}<SourceList />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ctx}
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; 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); }
|
||||
.header-left { 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); color: var(--text-faint); transition: background var(--t-base), color var(--t-base); white-space: nowrap; }
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-5) 0 var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
||||
.section { margin-bottom: var(--sp-6); }
|
||||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-6) var(--sp-3); }
|
||||
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); 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; }
|
||||
.see-all { 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); }
|
||||
.see-all:hover { color: var(--accent-fg); }
|
||||
.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 { 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); }
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||
.progress-bar { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: var(--bg-overlay); }
|
||||
.progress-fill { 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 { flex-shrink: 0; width: 110px; aspect-ratio: 2/3; pointer-events: none; visibility: hidden; }
|
||||
.skeleton-row { display: flex; gap: var(--sp-3); padding: 0 var(--sp-6); overflow: hidden; }
|
||||
.card-skeleton { flex-shrink: 0; width: 110px; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 80%; }
|
||||
.explore-more-card { flex-shrink: 0; width: 110px; aspect-ratio: 2/3; border-radius: var(--radius-md); border: 1px dashed var(--border-strong); background: var(--bg-raised); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: border-color var(--t-base), background var(--t-base); padding: 0; }
|
||||
.explore-more-card:hover { border-color: var(--accent); background: var(--accent-muted); }
|
||||
.explore-more-inner { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-3); pointer-events: none; }
|
||||
:global(.explore-more-icon) { color: var(--text-faint); transition: color var(--t-base); }
|
||||
.explore-more-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); text-align: center; }
|
||||
.explore-more-genre { font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; text-align: center; font-family: var(--font-ui); letter-spacing: var(--tracking-wide); }
|
||||
.no-source { 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); }
|
||||
.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; }
|
||||
.empty-hint { font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; }
|
||||
.retry-btn { margin-top: var(--sp-3); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
</style>
|
||||
@@ -130,7 +130,18 @@
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Extensions</h1>
|
||||
<div class="header-actions">
|
||||
<div class="tabs">
|
||||
{#each FILTERS as f}
|
||||
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} />
|
||||
</div>
|
||||
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
|
||||
<GitBranch size={14} weight="light" />
|
||||
</button>
|
||||
@@ -153,7 +164,7 @@
|
||||
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
|
||||
bind:value={externalUrl} disabled={installing}
|
||||
oninput={() => installError = null}
|
||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} autofocus />
|
||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} use:focusOnMount />
|
||||
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
||||
@@ -201,19 +212,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="controls">
|
||||
<div class="tabs">
|
||||
{#each FILTERS as f}
|
||||
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{#if loading}
|
||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
@@ -282,9 +281,10 @@
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0; }
|
||||
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-actions { display: flex; gap: var(--sp-1); }
|
||||
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.4; }
|
||||
@@ -309,11 +309,11 @@
|
||||
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
||||
.controls { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0; }
|
||||
.tabs { display: flex; gap: 2px; }
|
||||
.tab { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: none; background: none; color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
|
||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
||||
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||
@@ -328,7 +328,6 @@
|
||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
||||
.update-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 2px 6px; flex-shrink: 0; }
|
||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
||||
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
||||
@@ -346,3 +345,7 @@
|
||||
.variant-actions { flex-shrink: 0; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
||||
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
|
||||
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga, Source, Category } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -35,19 +35,21 @@
|
||||
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let sourceManga: Manga[] = $state([]);
|
||||
let loadingInitial = true;
|
||||
let loadingMore = false;
|
||||
let visibleCount = PAGE_SIZE;
|
||||
let loadingInitial = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let visibleCount = $state(PAGE_SIZE);
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
let categories: Category[] = $state([]);
|
||||
let catsLoaded = false;
|
||||
|
||||
const nextPageMap = new Map<string, number>();
|
||||
let sources: Source[] = $state([]);
|
||||
let abortCtrl: AbortController | null = null;
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]);
|
||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
|
||||
});
|
||||
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
||||
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
||||
@@ -143,19 +145,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
e.preventDefault();
|
||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||
if (!catsLoaded) {
|
||||
catsLoaded = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
return [
|
||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||
...(store.settings.folders.length > 0 ? [
|
||||
...(categories.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...store.settings.folders.map((f): MenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
...categories.map((cat): MenuEntry => ({
|
||||
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
|
||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (!name?.trim()) return;
|
||||
const res = await gql<{ createCategory: { category: Category } }>(
|
||||
CREATE_CATEGORY,
|
||||
{ name: name.trim() }
|
||||
).catch(console.error);
|
||||
if (res) {
|
||||
const cat = (res as any).createCategory.category;
|
||||
categories = [...categories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||
}
|
||||
}},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -190,7 +215,7 @@
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each visibleItems as m (m.id)}
|
||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, X as XIcon } from "phosphor-svelte";
|
||||
import { thumbUrl, gql } from "../../lib/client";
|
||||
import { GET_CHAPTERS } from "../../lib/queries";
|
||||
import { store, openReader, clearHistory, clearHistoryForManga } from "../../store/state.svelte";
|
||||
import type { HistoryEntry } from "../../store/state.svelte";
|
||||
|
||||
let search = $state("");
|
||||
let confirmClearAll = $state(false);
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "Just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function dayLabel(ts: number): string {
|
||||
const d = new Date(ts), now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return "Today";
|
||||
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
||||
const weekAgo = new Date(now); weekAgo.setDate(now.getDate() - 7);
|
||||
if (d > weekAgo) return d.toLocaleDateString("en-US", { weekday: "long" });
|
||||
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined });
|
||||
}
|
||||
|
||||
function formatReadTime(mins: number): string {
|
||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
||||
if (mins < 60) return `${Math.round(mins)}m`;
|
||||
const h = Math.floor(mins / 60), r = mins % 60;
|
||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||
const d = Math.floor(h / 24), rh = h % 24;
|
||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
||||
}
|
||||
|
||||
const SESSION_GAP_MS = 30 * 60 * 1000;
|
||||
|
||||
interface Session {
|
||||
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
||||
latestChapterId: number; latestChapterName: string; latestPageNumber: number;
|
||||
firstChapterName: string; chapterCount: number; readAt: number;
|
||||
}
|
||||
|
||||
function buildSessions(entries: HistoryEntry[]): Session[] {
|
||||
if (!entries.length) return [];
|
||||
const sessions: Session[] = [];
|
||||
let i = 0;
|
||||
while (i < entries.length) {
|
||||
const anchor = entries[i];
|
||||
const group: HistoryEntry[] = [anchor];
|
||||
let j = i + 1;
|
||||
while (j < entries.length) {
|
||||
const next = entries[j];
|
||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) { group.push(next); j++; }
|
||||
else break;
|
||||
}
|
||||
const latest = group[0], oldest = group[group.length - 1];
|
||||
sessions.push({ mangaId: latest.mangaId, mangaTitle: latest.mangaTitle, thumbnailUrl: latest.thumbnailUrl, latestChapterId: latest.chapterId, latestChapterName: latest.chapterName, latestPageNumber: latest.pageNumber, firstChapterName: oldest.chapterName, chapterCount: group.length, readAt: latest.readAt });
|
||||
i = j;
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
const filtered = $derived(search.trim()
|
||||
? store.history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
||||
: store.history);
|
||||
|
||||
const sessions = $derived(buildSessions(filtered));
|
||||
|
||||
const groups = $derived((() => {
|
||||
const map = new Map<string, Session[]>();
|
||||
for (const s of sessions) {
|
||||
const l = dayLabel(s.readAt);
|
||||
if (!map.has(l)) map.set(l, []);
|
||||
map.get(l)!.push(s);
|
||||
}
|
||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||
})());
|
||||
|
||||
const stats = $derived({
|
||||
uniqueChapters: new Set(store.history.map(e => e.chapterId)).size,
|
||||
uniqueManga: new Set(store.history.map(e => e.mangaId)).size,
|
||||
estimatedMinutes: Math.round(new Set(store.history.map(e => e.chapterId)).size * 4.5),
|
||||
});
|
||||
|
||||
function doConfirmClear() { clearHistory(); confirmClearAll = false; }
|
||||
|
||||
async function resume(session: Session) {
|
||||
try {
|
||||
const d = await gql<{ chapters: { nodes: any[] } }>(GET_CHAPTERS, { mangaId: session.mangaId });
|
||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const ch = chapters.find(c => c.id === session.latestChapterId) ?? chapters[0];
|
||||
if (ch) openReader(ch, chapters);
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">History</h1>
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search store.history…" bind:value={search} />
|
||||
{#if search}
|
||||
<button class="search-clear" onclick={() => search = ""}>
|
||||
<XIcon size={10} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if store.history.length > 0}
|
||||
{#if confirmClearAll}
|
||||
<div class="confirm-row">
|
||||
<span class="confirm-label">Clear all activity?</span>
|
||||
<button class="confirm-yes" onclick={doConfirmClear}>Clear</button>
|
||||
<button class="confirm-no" onclick={() => confirmClearAll = false}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="clear-btn" onclick={() => confirmClearAll = true} title="Clear all activity">
|
||||
<Trash size={13} weight="light" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<span class="stat-item"><span class="stat-val">{stats.uniqueChapters}</span><span class="stat-label">chapters</span></span>
|
||||
<span class="stat-sep"></span>
|
||||
<span class="stat-item"><span class="stat-val">{stats.uniqueManga}</span><span class="stat-label">series</span></span>
|
||||
<span class="stat-sep"></span>
|
||||
<span class="stat-item"><span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span><span class="stat-label">est. time</span></span>
|
||||
{#if store.readingStats.currentStreakDays > 0}
|
||||
<span class="stat-sep"></span>
|
||||
<span class="stat-item"><span class="stat-val">{store.readingStats.currentStreakDays}d</span><span class="stat-label">streak</span></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if store.history.length === 0}
|
||||
<div class="empty">
|
||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
||||
<p class="empty-text">No reading history yet</p>
|
||||
<p class="empty-hint">Chapters you read will appear here</p>
|
||||
</div>
|
||||
{:else if sessions.length === 0}
|
||||
<div class="empty">
|
||||
<Books size={28} weight="light" class="empty-icon" />
|
||||
<p class="empty-text">No results for "{search}"</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each groups as { label, items } (label)}
|
||||
<div class="group">
|
||||
<p class="group-label">
|
||||
<span>{label}</span>
|
||||
<span class="group-count">{items.length}</span>
|
||||
</p>
|
||||
{#each items as session (session.latestChapterId + ":" + session.readAt)}
|
||||
<div class="row-wrap">
|
||||
<button class="row" onclick={() => resume(session)}>
|
||||
<div class="thumb-wrap">
|
||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" loading="lazy" decoding="async" />
|
||||
{#if session.chapterCount > 1}
|
||||
<span class="session-badge">{session.chapterCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="info">
|
||||
<span class="manga-title">{session.mangaTitle}</span>
|
||||
<span class="chapter-name">
|
||||
{#if session.chapterCount > 1}
|
||||
<span class="chapter-range">{session.firstChapterName}<span class="range-sep">→</span>{session.latestChapterName}</span>
|
||||
{:else}
|
||||
{session.latestChapterName}
|
||||
{#if session.latestPageNumber > 1}<span class="page-badge">p.{session.latestPageNumber}</span>{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span class="time">{timeAgo(session.readAt)}</span>
|
||||
<Play size={11} weight="fill" class="play-icon" />
|
||||
</button>
|
||||
<button class="row-delete" onclick={() => clearHistoryForManga(session.mangaId)} title="Remove {session.mangaTitle} from store.history" aria-label="Remove from store.history">
|
||||
<XIcon size={9} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 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); }
|
||||
.search-clear { position: absolute; right: 7px; display: flex; align-items: center; justify-content: center; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||
.search-clear:hover { color: var(--text-muted); }
|
||||
.confirm-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.confirm-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.confirm-yes { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--color-error); background: var(--color-error-bg); color: var(--color-error); cursor: pointer; transition: filter var(--t-base); }
|
||||
.confirm-yes:hover { filter: brightness(1.15); }
|
||||
.confirm-no { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: background var(--t-base); }
|
||||
.confirm-no:hover { background: var(--bg-raised); color: var(--text-muted); }
|
||||
.clear-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.stats-bar { display: flex; align-items: center; gap: var(--sp-3); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; }
|
||||
.stat-item { display: flex; align-items: baseline; gap: 4px; }
|
||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.stat-sep { width: 1px; height: 10px; background: var(--border-dim); flex-shrink: 0; }
|
||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-6); scrollbar-width: none; }
|
||||
.list::-webkit-scrollbar { display: none; }
|
||||
.group { margin-bottom: var(--sp-4); }
|
||||
.group-label { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-1) var(--sp-2) var(--sp-2); }
|
||||
.group-count { font-family: var(--font-ui); font-size: 9px; color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); padding: 1px 5px; border-radius: var(--radius-full); letter-spacing: 0; text-transform: none; }
|
||||
.row-wrap { display: flex; align-items: center; border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||
.row-wrap:hover { background: var(--bg-raised); }
|
||||
.row-wrap:hover .row-delete { opacity: 1; }
|
||||
.row { flex: 1; display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; min-width: 0; }
|
||||
.row:hover :global(.play-icon) { opacity: 1; }
|
||||
.row-delete { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-base), color var(--t-base), background var(--t-base); margin-right: var(--sp-1); }
|
||||
.row-delete:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
||||
.thumb { width: 36px; height: 52px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.session-badge { position: absolute; bottom: -4px; right: -6px; background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: 9px; font-weight: 600; padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none; }
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||
.manga-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chapter-name { font-size: var(--text-sm); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
|
||||
.chapter-range { display: flex; align-items: center; gap: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted); }
|
||||
.range-sep { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
||||
.page-badge { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
||||
:global(.play-icon) { color: var(--text-faint); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
||||
:global(.empty-icon) { color: var(--text-faint); }
|
||||
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
||||
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -2,11 +2,11 @@
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } from "../../lib/queries";
|
||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { store, openReader, COMPLETED_FOLDER_ID, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
||||
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
|
||||
import type { HistoryEntry } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||
@@ -22,7 +22,7 @@
|
||||
function formatReadTime(mins: number): string {
|
||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
||||
if (mins < 60) return `${Math.round(mins)}m`;
|
||||
const h = Math.floor(mins / 60), r = mins % 60;
|
||||
const h = Math.floor(mins / 60), r = Math.round(mins % 60);
|
||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||
const d = Math.floor(h / 24), rh = h % 24;
|
||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
||||
@@ -33,17 +33,45 @@
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let extraManga: Manga[] = $state([]);
|
||||
let loadingLibrary: boolean = $state(true);
|
||||
let completedCategory: Category | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
|
||||
.catch(console.error)
|
||||
.finally(() => loadingLibrary = false);
|
||||
loadLibrary();
|
||||
});
|
||||
|
||||
async function fetchExtraCompleted(library: Manga[]) {
|
||||
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? [];
|
||||
function loadLibrary() {
|
||||
const libraryP = cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||
);
|
||||
const categoriesP = gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => d.categories.nodes.find(c => c.name === "Completed") ?? null)
|
||||
.catch(() => null);
|
||||
|
||||
Promise.all([libraryP, categoriesP])
|
||||
.then(([m, completed]) => {
|
||||
libraryManga = m;
|
||||
completedCategory = completed;
|
||||
fetchExtraCompleted(m, completed);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => loadingLibrary = false);
|
||||
}
|
||||
|
||||
// Re-fetch library and reset hero chapters whenever the reader closes,
|
||||
// so the hero reflects the latest-read chapter immediately.
|
||||
$effect(() => {
|
||||
const sessionId = store.readerSessionId;
|
||||
if (sessionId === 0) return; // skip initial mount — onMount handles that
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
loadingLibrary = true;
|
||||
heroChapters = [];
|
||||
heroAllChapters = [];
|
||||
heroChaptersFor = null;
|
||||
loadLibrary();
|
||||
});
|
||||
|
||||
async function fetchExtraCompleted(library: Manga[], completed: Category | null) {
|
||||
const completedIds = completed?.mangas?.nodes.map(m => m.id) ?? [];
|
||||
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
||||
if (!missingIds.length) return;
|
||||
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
||||
@@ -92,9 +120,9 @@
|
||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
||||
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
||||
|
||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; }
|
||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; }
|
||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; } }
|
||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
||||
@@ -108,6 +136,7 @@
|
||||
|
||||
let heroStageH = $state(300);
|
||||
let heroChapters: Chapter[] = $state([]);
|
||||
let heroAllChapters: Chapter[] = $state([]);
|
||||
let loadingHeroChapters = $state(false);
|
||||
let heroChaptersFor: number | null = null;
|
||||
|
||||
@@ -120,14 +149,16 @@
|
||||
heroChaptersFor = mangaId;
|
||||
loadingHeroChapters = true;
|
||||
heroChapters = [];
|
||||
heroAllChapters = [];
|
||||
try {
|
||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
||||
if (heroChaptersFor !== mangaId) return;
|
||||
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
heroAllChapters = all;
|
||||
const lastReadIdx = heroEntry ? all.findIndex(c => c.id === heroEntry!.chapterId) : all.findLastIndex(c => c.isRead);
|
||||
const startIdx = Math.max(0, lastReadIdx);
|
||||
heroChapters = all.slice(startIdx, startIdx + 5);
|
||||
} catch { heroChapters = []; }
|
||||
} catch { heroChapters = []; heroAllChapters = []; }
|
||||
finally { loadingHeroChapters = false; }
|
||||
}
|
||||
|
||||
@@ -137,12 +168,16 @@
|
||||
if (!heroMangaId) return;
|
||||
resuming = true;
|
||||
try {
|
||||
let all = heroChapters;
|
||||
let all = heroAllChapters;
|
||||
if (!all.length) {
|
||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
}
|
||||
if (all.length) {
|
||||
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
|
||||
store.activeManga = manga;
|
||||
openReader(chapter, all);
|
||||
}
|
||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
||||
finally { resuming = false; }
|
||||
}
|
||||
@@ -150,15 +185,17 @@
|
||||
async function resumeActive() {
|
||||
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
||||
if (!heroEntry) return;
|
||||
const target = heroChapters.find(c => c.id === heroEntry!.chapterId) ?? heroChapters[0];
|
||||
if (target && heroChapters.length) { await openChapter(target); return; }
|
||||
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
|
||||
if (target && heroAllChapters.length) { await openChapter(target); return; }
|
||||
resuming = true;
|
||||
try {
|
||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
||||
if (ch) openReader(ch, chapters);
|
||||
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
||||
if (ch) {
|
||||
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
||||
openReader(ch, chapters);
|
||||
}
|
||||
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
||||
finally { resuming = false; }
|
||||
}
|
||||
@@ -168,8 +205,10 @@
|
||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
||||
if (ch) openReader(ch, chapters);
|
||||
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||
if (ch) {
|
||||
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||
openReader(ch, chapters);
|
||||
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
||||
}
|
||||
|
||||
@@ -186,10 +225,10 @@
|
||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
||||
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
||||
|
||||
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
|
||||
const completedIds = $derived(completedCategory?.mangas?.nodes.map(m => m.id) ?? []);
|
||||
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
|
||||
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []);
|
||||
const recentHistory = $derived(store.history.slice(0, 8));
|
||||
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 7) : []);
|
||||
const recentHistory = $derived(store.history.slice(0, 6));
|
||||
const stats = $derived(store.readingStats);
|
||||
|
||||
function handleRowWheel(e: WheelEvent) {
|
||||
@@ -384,7 +423,7 @@
|
||||
<div class="bottom-section-hd">
|
||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
||||
{#if completedManga.length > 0}
|
||||
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
|
||||
<button class="see-all" onclick={() => { if (completedCategory) setLibraryFilter(String(completedCategory.id)); store.navPage = "library"; }}>View all <ArrowRight size={9} weight="bold" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if completedManga.length > 0}
|
||||
@@ -428,7 +467,9 @@
|
||||
</div>
|
||||
|
||||
{#if pickerOpen}
|
||||
<div class="picker-backdrop" onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}>
|
||||
<div class="picker-backdrop" role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}
|
||||
onkeydown={(e) => { if (e.key === "Escape") closePicker(); }}>
|
||||
<div class="picker-modal">
|
||||
<div class="picker-header">
|
||||
<span class="picker-title">Pin manga — slot {(pickerSlotIndex ?? 0) + 1}</span>
|
||||
@@ -549,9 +590,10 @@
|
||||
.bottom-col:last-child { padding-left: var(--sp-4); }
|
||||
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
||||
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
||||
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--sp-3); }
|
||||
.mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
|
||||
.mini-row::-webkit-scrollbar { display: none; }
|
||||
|
||||
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||
.mini-card:hover { will-change: transform; }
|
||||
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||
import { store } from "../../store/state.svelte";
|
||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
@@ -36,8 +38,47 @@
|
||||
let sources: Source[] = $state([]);
|
||||
let loadingSources = $state(true);
|
||||
let selectedSource: Source | null = $state(null);
|
||||
const _initialTitle = manga.title;
|
||||
let query = $state(_initialTitle);
|
||||
|
||||
// Lang filter: "en" first, then alphabetical
|
||||
let selectedLang: string = $state("all");
|
||||
let langStripEl: HTMLDivElement | undefined = $state();
|
||||
const availableLangs = $derived.by(() => {
|
||||
const langs = Array.from(new Set<string>(sources.map(s => s.lang))).sort();
|
||||
const en = langs.indexOf("en");
|
||||
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
|
||||
return langs;
|
||||
});
|
||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||
|
||||
function scrollLangStrip(dir: -1 | 1) {
|
||||
if (!langStripEl) return;
|
||||
const strip = langStripEl;
|
||||
const chips = Array.from(strip.children) as HTMLElement[];
|
||||
const scrollLeft = strip.scrollLeft;
|
||||
const viewEnd = scrollLeft + strip.clientWidth;
|
||||
|
||||
if (dir === 1) {
|
||||
// Find first chip that is cut off or fully outside the right edge, scroll it flush left
|
||||
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
|
||||
if (next) strip.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
|
||||
} else {
|
||||
// Find last chip that is cut off or fully outside the left edge, scroll it flush right
|
||||
const prev = [...chips].reverse().find(c => c.offsetLeft < scrollLeft - 2);
|
||||
if (prev) strip.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - strip.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
const visibleSources = $derived.by(() => {
|
||||
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of sources) {
|
||||
const existing = map.get(s.name);
|
||||
if (!existing) { map.set(s.name, s); continue; }
|
||||
if (s.lang < existing.lang) map.set(s.name, s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
|
||||
let query = $state(untrack(() => manga.title));
|
||||
let results: { manga: Manga; similarity: number }[] = $state([]);
|
||||
let searching = $state(false);
|
||||
let selectedMatch: Match | null = $state(null);
|
||||
@@ -52,7 +93,14 @@
|
||||
|
||||
$effect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => { sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id); })
|
||||
.then((d) => {
|
||||
const filtered = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id);
|
||||
sources = filtered;
|
||||
// Pre-select preferred lang if available and there are multiple
|
||||
const prefLang = store?.settings?.preferredExtensionLang ?? "";
|
||||
const langs = new Set(filtered.map(s => s.lang));
|
||||
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingSources = false; });
|
||||
|
||||
@@ -178,7 +226,6 @@
|
||||
|
||||
<!-- Step 1: Pick source -->
|
||||
{#if step === "source"}
|
||||
<div class="source-list">
|
||||
{#if loadingSources}
|
||||
<div class="centered">
|
||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
@@ -186,7 +233,22 @@
|
||||
{:else if sources.length === 0}
|
||||
<div class="centered"><span class="hint">No other sources installed.</span></div>
|
||||
{:else}
|
||||
{#each sources as src}
|
||||
{#if hasMultipleLangs}
|
||||
<div class="src-lang-bar">
|
||||
<button class="src-lang-nav" onclick={() => scrollLangStrip(-1)}>‹</button>
|
||||
<div class="src-lang-chips" bind:this={langStripEl}>
|
||||
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === "all"} onclick={() => selectedLang = "all"}>All</button>
|
||||
{#each availableLangs as lang}
|
||||
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === lang} onclick={() => selectedLang = lang}>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="src-lang-nav" onclick={() => scrollLangStrip(1)}>›</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="source-list">
|
||||
{#each visibleSources as src}
|
||||
<button
|
||||
class="source-row"
|
||||
class:source-row-active={selectedSource?.id === src.id}
|
||||
@@ -200,8 +262,8 @@
|
||||
<ArrowRight size={13} weight="light" class="source-arrow" />
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 2: Search & pick match -->
|
||||
{:else if step === "search"}
|
||||
@@ -222,7 +284,7 @@
|
||||
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
||||
<input class="search-input" bind:value={query}
|
||||
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||
placeholder="Search title…" autofocus />
|
||||
placeholder="Search title…" use:focusOnMount />
|
||||
</div>
|
||||
<button class="search-btn"
|
||||
onclick={() => selectedSource && searchSource(selectedSource, query)}
|
||||
@@ -400,6 +462,18 @@
|
||||
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); }
|
||||
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
||||
|
||||
/* Lang filter bar */
|
||||
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.src-lang-nav:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; scroll-behavior: smooth; }
|
||||
.src-lang-chip:last-child { margin-right: var(--sp-1); }
|
||||
.src-lang-chips::-webkit-scrollbar { display: none; }
|
||||
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.src-lang-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.src-lang-chip-active:hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
|
||||
/* Search step */
|
||||
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
|
||||
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
|
||||
@@ -471,3 +545,7 @@
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X } from "phosphor-svelte";
|
||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage} from "../../store/state.svelte";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga } from "../../store/state.svelte";
|
||||
import type { MangaPrefs } from "../../store/state.svelte";
|
||||
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import MigrateModal from "./MigrateModal.svelte";
|
||||
import TrackingPanel from "../shared/TrackingPanel.svelte";
|
||||
import AutomationPanel from "../shared/AutomationPanel.svelte";
|
||||
import MarkersPanel from "../shared/MarkersPanel.svelte";
|
||||
|
||||
const CHAPTERS_PER_PAGE = 25;
|
||||
const MANGA_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -22,7 +28,7 @@
|
||||
let loadingChapters: boolean = $state(true);
|
||||
let enqueueing: Set<number> = $state(new Set());
|
||||
let dlOpen: boolean = $state(false);
|
||||
let detailsOpen: boolean = $state(false);
|
||||
let manageOpen: boolean = $state(false);
|
||||
let togglingLibrary: boolean = $state(false);
|
||||
let chapterPage: number = $state(1);
|
||||
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
|
||||
@@ -31,42 +37,67 @@
|
||||
let viewMode: "list" | "grid" = $state("list");
|
||||
let deletingAll: boolean = $state(false);
|
||||
let refreshing: boolean = $state(false);
|
||||
let descExpanded: boolean = $state(false);
|
||||
let genresExpanded: boolean = $state(false);
|
||||
let folderPickerOpen: boolean = $state(false);
|
||||
let folderCreating: boolean = $state(false);
|
||||
let folderNewName: string = $state("");
|
||||
let mangaCategories: Category[] = $state([]);
|
||||
let allCategories: Category[] = $state([]);
|
||||
let catsLoading: boolean = $state(false);
|
||||
let rangeFrom: string = $state("");
|
||||
let rangeTo: string = $state("");
|
||||
let showRange: boolean = $state(false);
|
||||
let migrateOpen: boolean = $state(false);
|
||||
let autoOpen: boolean = $state(false);
|
||||
let trackingOpen: boolean = $state(false);
|
||||
let markersOpen: boolean = $state(false);
|
||||
let linkPickerOpen: boolean = $state(false);
|
||||
let linkSearch: string = $state("");
|
||||
let allMangaForLink: Manga[] = $state([]);
|
||||
let loadingLinkList: boolean = $state(false);
|
||||
let selectedIds: Set<number> = $state(new Set());
|
||||
let sortMenuOpen: boolean = $state(false);
|
||||
let dlDropRef: HTMLDivElement | undefined = $state();
|
||||
let folderPickerRef: HTMLDivElement | undefined = $state();
|
||||
|
||||
let mangaAbort: AbortController | null = null;
|
||||
let chapterAbort: AbortController | null = null;
|
||||
let loadingFor: number | null = null;
|
||||
let _prevChapterIds: Set<number> = new Set();
|
||||
|
||||
function formatDate(ts: string | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
const n = Number(ts);
|
||||
const d = new Date(n > 1e10 ? n : n * 1000);
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
|
||||
const mangaPrefs = $derived.by((): Partial<MangaPrefs> => {
|
||||
if (!store.activeManga) return {};
|
||||
return store.settings.mangaPrefs?.[store.activeManga.id] ?? {};
|
||||
});
|
||||
|
||||
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
|
||||
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
|
||||
}
|
||||
|
||||
function applyChapters(nodes: Chapter[]) {
|
||||
chapters = nodes;
|
||||
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
||||
}
|
||||
const hasSelection = $derived(selectedIds.size > 0);
|
||||
|
||||
const sortDir = $derived(store.settings.chapterSortDir);
|
||||
const sortedChapters = $derived(sortDir === "desc" ? [...chapters].reverse() : [...chapters]);
|
||||
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
|
||||
|
||||
const sortedChapters = $derived.by(() => {
|
||||
const base = [...chapters];
|
||||
if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
||||
else if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
|
||||
else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
return sortDir === "desc" ? base.reverse() : base;
|
||||
});
|
||||
|
||||
const chaptersAsc = $derived([...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder));
|
||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
||||
const readCount = $derived(chapters.filter(c => c.isRead).length);
|
||||
const totalCount = $derived(chapters.length);
|
||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
||||
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
|
||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
||||
const hasFolders = $derived(assignedFolders.length > 0);
|
||||
|
||||
const continueChapter = $derived((() => {
|
||||
if (!chapters.length) return null;
|
||||
@@ -79,9 +110,95 @@
|
||||
return { chapter: asc[0], type: "reread" as const };
|
||||
})());
|
||||
|
||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
|
||||
const hasFolders = $derived(assignedFolders.length > 0);
|
||||
const jumpChapter = $derived.by(() => {
|
||||
const q = jumpInput.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
const num = parseFloat(q);
|
||||
if (!isNaN(num)) return sortedChapters.find(c => c.chapterNumber === num) ?? null;
|
||||
return sortedChapters.find(c => c.name.toLowerCase().includes(q)) ?? null;
|
||||
});
|
||||
|
||||
const hasAnyAutomation = $derived(
|
||||
getPref("autoDownload") ||
|
||||
getPref("downloadAhead") > 0 ||
|
||||
getPref("maxKeepChapters") > 0 ||
|
||||
getPref("deleteOnRead") ||
|
||||
getPref("pauseUpdates") ||
|
||||
getPref("refreshInterval") !== "global" ||
|
||||
!!getPref("preferredScanlator")
|
||||
);
|
||||
|
||||
const linkedIds = $derived(
|
||||
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
||||
);
|
||||
|
||||
const linkPickerResults = $derived.by(() => {
|
||||
const id = store.activeManga?.id;
|
||||
const others = allMangaForLink.filter(m => m.id !== id);
|
||||
const q = linkSearch.trim().toLowerCase();
|
||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
||||
return [...linked, ...rest];
|
||||
});
|
||||
|
||||
function doJump() {
|
||||
if (!jumpChapter) return;
|
||||
const pageIdx = sortedChapters.indexOf(jumpChapter);
|
||||
if (pageIdx >= 0) chapterPage = Math.floor(pageIdx / CHAPTERS_PER_PAGE) + 1;
|
||||
jumpOpen = false;
|
||||
jumpInput = "";
|
||||
}
|
||||
|
||||
function toggleSelect(id: number, e: MouseEvent | KeyboardEvent) {
|
||||
e.stopPropagation();
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
selectedIds = next;
|
||||
}
|
||||
|
||||
function clearSelection() { selectedIds = new Set(); }
|
||||
|
||||
function formatDate(ts: string | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
const n = Number(ts);
|
||||
const d = new Date(n > 1e10 ? n : n * 1000);
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function applyChapters(nodes: Chapter[]) {
|
||||
if (getPref("autoDownload") && _prevChapterIds.size > 0) {
|
||||
const newChapters = nodes.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
|
||||
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
|
||||
}
|
||||
_prevChapterIds = new Set(nodes.map(c => c.id));
|
||||
chapters = nodes;
|
||||
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
||||
}
|
||||
|
||||
function loadCategories(mangaId: number) {
|
||||
catsLoading = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => {
|
||||
allCategories = d.categories.nodes.filter(c => c.id !== 0);
|
||||
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { catsLoading = false; });
|
||||
}
|
||||
|
||||
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||
if (chaps.length) {
|
||||
const allRead = chaps.every(c => c.isRead);
|
||||
const completed = allCategories.find(c => c.name === "Completed");
|
||||
if (completed) {
|
||||
const inCompleted = mangaCategories.some(c => c.id === completed.id);
|
||||
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
|
||||
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadManga(id: number) {
|
||||
mangaAbort?.abort();
|
||||
@@ -142,7 +259,7 @@
|
||||
|
||||
$effect(() => {
|
||||
const m = store.activeManga;
|
||||
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); });
|
||||
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); loadCategories(m.id); });
|
||||
});
|
||||
|
||||
let prevChapterId: number | null = null;
|
||||
@@ -155,6 +272,18 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
|
||||
else document.removeEventListener("mousedown", handleDlOutside, true);
|
||||
});
|
||||
$effect(() => {
|
||||
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
|
||||
else document.removeEventListener("mousedown", handleFolderOutside, true);
|
||||
});
|
||||
|
||||
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
|
||||
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
|
||||
|
||||
async function toggleLibrary() {
|
||||
if (!manga) return;
|
||||
togglingLibrary = true;
|
||||
@@ -192,6 +321,14 @@
|
||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||
if (isRead && getPref("deleteOnRead")) {
|
||||
const ch = chapters.find(c => c.id === chapterId);
|
||||
if (ch?.isDownloaded) {
|
||||
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
|
||||
if (delayMs === 0) deleteDownloaded(chapterId);
|
||||
else setTimeout(() => deleteDownloaded(chapterId), delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function markBulk(ids: number[], isRead: boolean) {
|
||||
@@ -200,6 +337,40 @@
|
||||
const idSet = new Set(ids);
|
||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||
if (isRead && getPref("deleteOnRead")) {
|
||||
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||
if (toDelete.length) {
|
||||
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
|
||||
const doDelete = async () => {
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
|
||||
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
|
||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||
};
|
||||
if (delayMs === 0) doDelete();
|
||||
else setTimeout(doDelete, delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||
if (ids.length) {
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
||||
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, isDownloaded: false } : c);
|
||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||
}
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function downloadSelected() {
|
||||
const ids = [...selectedIds].filter(id => !chapters.find(c => c.id === id)?.isDownloaded);
|
||||
await enqueueMultiple(ids);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function markSelectedRead(isRead: boolean) {
|
||||
await markBulk([...selectedIds], isRead);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
|
||||
@@ -239,8 +410,8 @@
|
||||
return [
|
||||
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
|
||||
{ separator: true },
|
||||
{ label: "Mark above as read", icon: CheckCircle, onClick: () => markAboveRead(idx), disabled: idx === 0 || above.filter(c => !c.isRead).length === 0 },
|
||||
{ label: "Mark above as unread", icon: Circle, onClick: () => markAboveUnread(idx), disabled: idx === 0 || above.filter(c => c.isRead).length === 0 },
|
||||
{ label: "Mark above as read", icon: CheckCircle, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
|
||||
{ label: "Mark above as unread", icon: Circle, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
|
||||
{ separator: true },
|
||||
{ label: "Mark below as read", icon: CheckCircle, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
|
||||
{ label: "Mark below as unread", icon: Circle, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
||||
@@ -252,18 +423,6 @@
|
||||
];
|
||||
}
|
||||
|
||||
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
|
||||
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
|
||||
|
||||
$effect(() => {
|
||||
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
|
||||
else document.removeEventListener("mousedown", handleDlOutside, true);
|
||||
});
|
||||
$effect(() => {
|
||||
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
|
||||
else document.removeEventListener("mousedown", handleFolderOutside, true);
|
||||
});
|
||||
|
||||
function enqueueNext(n: number) {
|
||||
if (!continueChapter) return;
|
||||
const idx = sortedChapters.indexOf(continueChapter.chapter);
|
||||
@@ -278,14 +437,62 @@
|
||||
enqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id));
|
||||
}
|
||||
|
||||
function createFolder() {
|
||||
async function createCategory() {
|
||||
const name = folderNewName.trim();
|
||||
if (!name || !store.activeManga) return;
|
||||
const id = addFolder(name);
|
||||
assignMangaToFolder(id, store.activeManga.id);
|
||||
try {
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||
const cat = res.createCategory.category;
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.activeManga.id, addTo: [cat.id], removeFrom: [] });
|
||||
allCategories = [...allCategories, cat];
|
||||
mangaCategories = [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
folderNewName = ""; folderCreating = false;
|
||||
}
|
||||
|
||||
async function toggleCategory(cat: Category) {
|
||||
if (!store.activeManga) return;
|
||||
const inCat = mangaCategories.some(c => c.id === cat.id);
|
||||
try {
|
||||
await gql(UPDATE_MANGA_CATEGORIES, {
|
||||
mangaId: store.activeManga.id,
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
});
|
||||
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function openReaderWithAhead(ch: Chapter, list: Chapter[]) {
|
||||
const ahead = getPref("downloadAhead");
|
||||
if (ahead > 0) {
|
||||
const idx = list.indexOf(ch);
|
||||
if (idx >= 0) {
|
||||
const toQueue = list.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
|
||||
if (toQueue.length) enqueueMultiple(toQueue);
|
||||
}
|
||||
}
|
||||
openReader(ch, list);
|
||||
}
|
||||
|
||||
async function openLinkPicker() {
|
||||
linkPickerOpen = true; linkSearch = "";
|
||||
if (allMangaForLink.length) return;
|
||||
loadingLinkList = true;
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||
.then(d => { allMangaForLink = d.mangas.nodes; })
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingLinkList = false; });
|
||||
}
|
||||
|
||||
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
||||
|
||||
function handleLink(other: Manga) {
|
||||
if (!store.activeManga) return;
|
||||
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
|
||||
else linkManga(store.activeManga.id, other.id);
|
||||
}
|
||||
|
||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||
</script>
|
||||
|
||||
@@ -317,37 +524,31 @@
|
||||
{/if}
|
||||
{#if manga?.genre?.length}
|
||||
<div class="genres">
|
||||
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 5)) as g}
|
||||
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g}
|
||||
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button>
|
||||
{/each}
|
||||
{#if manga.genre.length > 5}
|
||||
{#if manga.genre.length > 3}
|
||||
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
|
||||
{genresExpanded ? "less" : `+${manga.genre.length - 5}`}
|
||||
{genresExpanded ? "less" : `+${manga.genre.length - 3}`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if manga?.description}
|
||||
<div class="desc-wrap">
|
||||
<p class="desc" class:expanded={descExpanded}>{manga.description}</p>
|
||||
{#if manga.description.length > 120}
|
||||
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>{descExpanded ? "Less" : "More"}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="desc">{manga.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if totalCount > 0}
|
||||
<div class="progress-section">
|
||||
<div class="progress-header">
|
||||
<span class="progress-label">{readCount} / {totalCount} read</span>
|
||||
<span class="progress-pct">{Math.round(progressPct)}%</span>
|
||||
</div>
|
||||
<div class="progress-track"><div class="progress-fill" style="width:{progressPct}%"></div></div>
|
||||
</div>
|
||||
<div class="cta-section">
|
||||
{#if continueChapter}
|
||||
<button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, chaptersAsc)}>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.type === "continue"
|
||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
||||
: continueChapter.type === "reread" ? "Read again" : "Start reading"}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button class="library-btn" class:active={manga?.inLibrary} onclick={toggleLibrary} disabled={togglingLibrary || loadingManga}>
|
||||
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
|
||||
@@ -359,38 +560,55 @@
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if continueChapter}
|
||||
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, sortedChapters)}>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.type === "continue"
|
||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
||||
: continueChapter.type === "reread" ? "Read again" : "Start reading"}
|
||||
</button>
|
||||
{#if totalCount > 0}
|
||||
<div class="progress-section">
|
||||
<div class="progress-header">
|
||||
<span class="progress-label">{readCount} / {totalCount} read</span>
|
||||
<span class="progress-pct">{Math.round(progressPct)}%</span>
|
||||
</div>
|
||||
<div class="progress-track"><div class="progress-fill" style="width:{progressPct}%"></div></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="chapter-count">{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}</p>
|
||||
|
||||
{#if !loadingManga && manga?.source}
|
||||
{#if !loadingManga && manga}
|
||||
<div class="details-section">
|
||||
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}>
|
||||
<span>Details</span>
|
||||
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
|
||||
<button class="details-toggle" onclick={() => manageOpen = !manageOpen}>
|
||||
<span>Manage</span>
|
||||
<CaretDown size={11} weight="light" style="transform:{manageOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
|
||||
</button>
|
||||
{#if detailsOpen}
|
||||
{#if manageOpen}
|
||||
<div class="details-body">
|
||||
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
|
||||
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
|
||||
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
|
||||
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
|
||||
<button class="migrate-btn" onclick={() => migrateOpen = true}>
|
||||
<ArrowsClockwise size={12} weight="light" /> Switch source
|
||||
<div class="detail-actions">
|
||||
<button class="detail-action-btn" onclick={() => setPreviewManga(manga)}>
|
||||
<Eye size={12} weight="light" /> Preview
|
||||
</button>
|
||||
{#if downloadedCount > 0}
|
||||
<button class="delete-all-btn" onclick={deleteAllDownloads} disabled={deletingAll}>
|
||||
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete downloads (${downloadedCount})`}
|
||||
<button class="detail-action-btn" onclick={() => migrateOpen = true}>
|
||||
<ArrowsClockwise size={12} weight="light" /> Switch Source
|
||||
</button>
|
||||
<button class="detail-action-btn" class:detail-action-active={linkedIds.length > 0} onclick={openLinkPicker}>
|
||||
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
||||
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
|
||||
</button>
|
||||
<button class="detail-action-btn" onclick={() => trackingOpen = true}>
|
||||
<ChartLineUp size={12} weight="light" /> Tracking
|
||||
</button>
|
||||
<button class="detail-action-btn" class:detail-action-active={markersOpen} onclick={() => markersOpen = !markersOpen}>
|
||||
<MapPin size={12} weight={markersOpen ? "fill" : "light"} />
|
||||
Markers{store.activeManga && store.getMarkersForManga(store.activeManga.id).length > 0 ? ` (${store.getMarkersForManga(store.activeManga.id).length})` : ""}
|
||||
</button>
|
||||
{#if manga?.inLibrary}
|
||||
<button class="detail-action-btn" class:detail-action-active={hasAnyAutomation} onclick={() => autoOpen = true}>
|
||||
<Gear size={12} weight={hasAnyAutomation ? "fill" : "light"} /> Automation
|
||||
</button>
|
||||
{/if}
|
||||
{#if downloadedCount > 0}
|
||||
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
|
||||
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -400,15 +618,58 @@
|
||||
<div class="list-wrap">
|
||||
<div class="list-header">
|
||||
<div class="list-header-left">
|
||||
<button class="sort-btn" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; }}>
|
||||
{#if hasSelection}
|
||||
<span class="sel-count">{selectedIds.size} selected</span>
|
||||
<button class="sel-action-btn" onclick={downloadSelected} title="Download selected"><Download size={13} weight="light" /></button>
|
||||
<button class="sel-action-btn sel-action-danger" onclick={deleteSelected} title="Delete selected downloads"><Trash size={13} weight="light" /></button>
|
||||
<button class="sel-action-btn" onclick={() => markSelectedRead(true)} title="Mark selected as read"><CheckCircle size={13} weight="light" /></button>
|
||||
<button class="sel-action-btn" onclick={() => markSelectedRead(false)} title="Mark selected as unread"><Circle size={13} weight="light" /></button>
|
||||
<button class="sel-action-btn" onclick={clearSelection} title="Clear selection"><X size={13} weight="light" /></button>
|
||||
{:else}
|
||||
<div class="sort-wrap">
|
||||
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
|
||||
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
||||
{sortDir === "desc" ? "Newest first" : "Oldest first"}
|
||||
{{ source: "Source order", chapterNumber: "Ch. number", uploadDate: "Upload date" }[sortMode]}
|
||||
<CaretDown size={10} weight="light" />
|
||||
</button>
|
||||
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"}>
|
||||
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
||||
{#if sortMenuOpen}
|
||||
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
|
||||
{#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]}
|
||||
<button class="sort-option" class:active={sortMode === val}
|
||||
onclick={() => { updateSettings({ chapterSortMode: val as any }); chapterPage = 1; sortMenuOpen = false; }}>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="sort-divider"></div>
|
||||
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; sortMenuOpen = false; }}>
|
||||
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
|
||||
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="list-header-right">
|
||||
<div class="jump-wrap">
|
||||
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
|
||||
<MagnifyingGlass size={14} weight="light" />
|
||||
</button>
|
||||
{#if jumpOpen}
|
||||
<div class="jump-popover">
|
||||
<input class="jump-input" placeholder="Chapter # or name…" bind:value={jumpInput} use:focusOnMount
|
||||
onkeydown={(e) => { if (e.key === "Enter") doJump(); if (e.key === "Escape") { jumpOpen = false; jumpInput = ""; } }} />
|
||||
{#if jumpChapter}
|
||||
<button class="jump-go" onclick={doJump}>Go · {jumpChapter.name}</button>
|
||||
{:else if jumpInput.trim()}
|
||||
<p class="jump-none">No match</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
@@ -419,25 +680,24 @@
|
||||
</button>
|
||||
{#if folderPickerOpen}
|
||||
<div class="fp-menu">
|
||||
{#if store.settings.folders.length === 0 && !folderCreating}
|
||||
{#if catsLoading}
|
||||
<p class="fp-empty">Loading…</p>
|
||||
{:else if allCategories.length === 0 && !folderCreating}
|
||||
<p class="fp-empty">No folders yet</p>
|
||||
{/if}
|
||||
{#each store.settings.folders as folder}
|
||||
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false}
|
||||
<button class="fp-item" class:fp-item-active={isIn}
|
||||
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}>
|
||||
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
|
||||
{#each allCategories as cat}
|
||||
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
|
||||
<button class="fp-item" class:fp-item-active={isIn} onclick={() => toggleCategory(cat)}>
|
||||
<span class="fp-check">{isIn ? "✓" : ""}</span>{cat.name}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="fp-div"></div>
|
||||
{#if folderCreating}
|
||||
<div class="fp-create">
|
||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} autofocus />
|
||||
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
|
||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
||||
<X size={12} weight="light" />
|
||||
</button>
|
||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} use:focusOnMount
|
||||
onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} />
|
||||
<button class="fp-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button>
|
||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}><X size={12} weight="light" /></button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
|
||||
@@ -446,39 +706,18 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chapters.length > 1}
|
||||
<div class="jump-wrap">
|
||||
{#if !jumpOpen}
|
||||
<button class="jump-toggle" onclick={() => { jumpOpen = true; jumpInput = ""; }}>Go to…</button>
|
||||
{:else}
|
||||
<div class="jump-row">
|
||||
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} autofocus
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") { jumpOpen = false; return; }
|
||||
if (e.key === "Enter") {
|
||||
const num = parseFloat(jumpInput);
|
||||
if (!isNaN(num)) {
|
||||
const target = sortedChapters.find(c => c.chapterNumber === num)
|
||||
?? sortedChapters.reduce((best, c) => Math.abs(c.chapterNumber - num) < Math.abs(best.chapterNumber - num) ? c : best, sortedChapters[0]);
|
||||
if (target) openReader(target, sortedChapters);
|
||||
}
|
||||
jumpOpen = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button class="jump-cancel" onclick={() => jumpOpen = false}>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if chapters.length > 0}
|
||||
<div class="dl-wrap" bind:this={dlDropRef}>
|
||||
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}>
|
||||
<Download size={13} weight="light" />
|
||||
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
|
||||
<Download size={13} weight={downloadedCount > 0 ? "fill" : "light"} />
|
||||
{#if downloadedCount > 0}<span class="dl-unified-count">{downloadedCount}</span>{/if}
|
||||
</button>
|
||||
{#if dlOpen}
|
||||
<div class="dl-dropdown">
|
||||
{#if downloadedCount > 0}
|
||||
<p class="dl-section-label">{downloadedCount} / {totalCount} downloaded</p>
|
||||
<div class="dl-divider"></div>
|
||||
{/if}
|
||||
{#if continueChapter}
|
||||
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
|
||||
{#if contIdx >= 0}
|
||||
@@ -501,7 +740,7 @@
|
||||
{:else}
|
||||
<div class="dl-range-row">
|
||||
<button class="dl-range-back" onclick={() => showRange = false}>‹</button>
|
||||
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} autofocus />
|
||||
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} use:focusOnMount />
|
||||
<span class="dl-range-sep">–</span>
|
||||
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
|
||||
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
|
||||
@@ -546,11 +785,13 @@
|
||||
{:else if viewMode === "grid"}
|
||||
{#each sortedChapters as ch, i}
|
||||
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:bookmarked={ch.isBookmarked}
|
||||
onclick={() => openReader(ch, sortedChapters)}
|
||||
{@const isGridSelected = selectedIds.has(ch.id)}
|
||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
|
||||
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc)}
|
||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
||||
title={ch.name}>
|
||||
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
||||
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
|
||||
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
||||
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
|
||||
</button>
|
||||
@@ -558,10 +799,14 @@
|
||||
{:else}
|
||||
{#each pageChapters as ch}
|
||||
{@const idxInSorted = sortedChapters.indexOf(ch)}
|
||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead}
|
||||
onclick={() => openReader(ch, sortedChapters)}
|
||||
onkeydown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)}
|
||||
{@const isSelected = selectedIds.has(ch.id)}
|
||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
|
||||
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc)}
|
||||
onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc))}
|
||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
||||
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => toggleSelect(ch.id, e)} title="Select">
|
||||
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
|
||||
</button>
|
||||
<div class="ch-left">
|
||||
<span class="ch-name">{ch.name}</span>
|
||||
<div class="ch-meta">
|
||||
@@ -573,11 +818,12 @@
|
||||
<div class="ch-right">
|
||||
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
||||
{#if ch.isDownloaded}
|
||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
|
||||
<span class="ch-dl-dot" title="Downloaded"></span>
|
||||
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }} title="Delete download"><Trash size={13} weight="light" /></button>
|
||||
{:else if enqueueing.has(ch.id)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
||||
{:else}
|
||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
|
||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }} title="Download"><Download size={13} weight="light" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -607,16 +853,71 @@
|
||||
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if trackingOpen && store.activeManga}
|
||||
<TrackingPanel mangaId={store.activeManga.id} mangaTitle={store.activeManga.title} onClose={() => trackingOpen = false} />
|
||||
{/if}
|
||||
|
||||
{#if autoOpen && store.activeManga}
|
||||
<AutomationPanel mangaId={store.activeManga.id} {chapters} onClose={() => autoOpen = false} />
|
||||
{/if}
|
||||
|
||||
{#if markersOpen && store.activeManga}
|
||||
<div class="markers-panel-overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) markersOpen = false; }}>
|
||||
<div class="markers-panel-drawer">
|
||||
<MarkersPanel mangaId={store.activeManga.id} {chapters} onClose={() => markersOpen = false} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if linkPickerOpen}
|
||||
<div class="link-backdrop" role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
||||
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
|
||||
<div class="link-modal">
|
||||
<div class="link-header">
|
||||
<span class="link-title">Link as same series</span>
|
||||
<button class="link-close" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
<p class="link-hint">Mark two manga as the same series so duplicates are merged in search and discover. Click a linked entry again to unlink.</p>
|
||||
<div class="link-search-wrap">
|
||||
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusOnMount />
|
||||
</div>
|
||||
<div class="link-list">
|
||||
{#if loadingLinkList}
|
||||
<p class="link-empty">Loading…</p>
|
||||
{:else if linkPickerResults.length === 0}
|
||||
<p class="link-empty">No results</p>
|
||||
{:else}
|
||||
{#each linkPickerResults as m (m.id)}
|
||||
{@const isLinked = linkedIds.includes(m.id)}
|
||||
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
|
||||
<div class="link-info">
|
||||
<span class="link-manga-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
||||
</div>
|
||||
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
<style>
|
||||
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.sidebar { width: 200px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
||||
|
||||
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
||||
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); }
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.sk-line { border-radius: var(--radius-sm); }
|
||||
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
@@ -630,47 +931,86 @@
|
||||
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
.desc-wrap { display: flex; flex-direction: column; gap: 2px; }
|
||||
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.desc.expanded { -webkit-line-clamp: unset; display: block; overflow: visible; }
|
||||
.desc-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); background: none; border: none; padding: 0; cursor: pointer; opacity: 0.7; transition: opacity var(--t-base); }
|
||||
.desc-toggle:hover { opacity: 1; }
|
||||
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.read-btn:hover { opacity: 0.88; }
|
||||
.actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.library-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); background: var(--bg-raised); transition: border-color var(--t-base), color var(--t-base), background var(--t-base); flex: 1; }
|
||||
.library-btn:hover { border-color: var(--accent); color: var(--accent-fg); }
|
||||
.library-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.library-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.progress-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||||
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
||||
.actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.library-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); background: var(--bg-raised); transition: border-color var(--t-base), color var(--t-base), background var(--t-base); flex: 1; }
|
||||
.library-btn:hover { border-color: var(--accent); color: var(--accent-fg); }
|
||||
.library-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.library-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.read-btn:hover { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||
.chapter-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
|
||||
|
||||
.details-section { display: flex; flex-direction: column; gap: 2px; }
|
||||
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
|
||||
.details-toggle:hover { color: var(--text-muted); }
|
||||
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
|
||||
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
|
||||
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
|
||||
.migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px var(--sp-2); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; }
|
||||
.delete-all-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.delete-all-btn:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
|
||||
.delete-all-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
|
||||
.detail-action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.detail-action-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.detail-action-active:hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
.detail-action-danger { color: var(--color-error); }
|
||||
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
|
||||
.detail-action-danger:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
|
||||
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
||||
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.link-close { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.link-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.link-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0; }
|
||||
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.link-search { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
||||
.link-search:focus { border-color: var(--border-strong); }
|
||||
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
||||
.link-list::-webkit-scrollbar { display: none; }
|
||||
.link-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; letter-spacing: var(--tracking-wide); }
|
||||
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||
.link-row:hover { background: var(--bg-raised); }
|
||||
.link-row-linked { background: var(--accent-muted) !important; }
|
||||
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
.sort-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||
.sort-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.sort-wrap { position: relative; }
|
||||
.sort-menu { position: absolute; top: calc(100% + 4px); left: 0; min-width: 160px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top left; }
|
||||
.sort-option { display: block; width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
||||
.sort-option:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.sort-option.active { color: var(--accent-fg); }
|
||||
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.jump-wrap { position: relative; }
|
||||
.jump-popover { position: absolute; top: calc(100% + 4px); right: 0; width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-2); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.jump-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 5px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; transition: border-color var(--t-base); }
|
||||
.jump-input:focus { border-color: var(--border-focus); }
|
||||
.jump-go { width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
|
||||
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.fp-wrap { position: relative; }
|
||||
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
|
||||
@@ -688,14 +1028,7 @@
|
||||
.fp-cancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
||||
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
|
||||
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||
.jump-wrap { position: relative; }
|
||||
.jump-toggle { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.jump-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.jump-row { display: flex; align-items: center; gap: 4px; }
|
||||
.jump-input { width: 64px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); outline: none; }
|
||||
.jump-input:focus { border-color: var(--border-focus); }
|
||||
.jump-cancel { font-size: 12px; color: var(--text-faint); padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
|
||||
.jump-cancel:hover { color: var(--text-muted); }
|
||||
|
||||
.dl-wrap { position: relative; }
|
||||
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
@@ -719,12 +1052,14 @@
|
||||
.dl-range-sep { color: var(--text-faint); font-size: var(--text-xs); }
|
||||
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
|
||||
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.ch-list { flex: 1; overflow-y: auto; }
|
||||
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
||||
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
||||
@@ -749,6 +1084,42 @@
|
||||
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
|
||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
||||
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
||||
|
||||
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1); }
|
||||
.sel-action-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||
.sel-action-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||
.sel-action-danger { color: var(--color-error) !important; }
|
||||
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
|
||||
|
||||
.dl-unified-btn { gap: 5px; padding: 0 8px; width: auto; min-width: 28px; }
|
||||
.dl-unified-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); transition: color var(--t-base); }
|
||||
.dl-unified-btn:hover .dl-unified-count,
|
||||
.dl-unified-btn.active .dl-unified-count { color: var(--text-secondary); }
|
||||
.dl-unified-btn.dl-has-count { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.dl-unified-btn.dl-has-count .dl-unified-count { color: var(--accent-fg); opacity: 0.8; }
|
||||
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
|
||||
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.ch-check { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); padding: 0; }
|
||||
.ch-row:hover .ch-check { opacity: 1; }
|
||||
.ch-check-visible { opacity: 1 !important; }
|
||||
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
|
||||
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
|
||||
|
||||
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
|
||||
.ch-row:hover .dl-btn-delete { opacity: 1; }
|
||||
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
|
||||
|
||||
.ch-dl-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-fg); flex-shrink: 0; opacity: 0.7; transition: opacity var(--t-fast); }
|
||||
.ch-row:hover .ch-dl-dot { opacity: 0; }
|
||||
|
||||
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
|
||||
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
|
||||
.markers-panel-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: stretch; justify-content: flex-start; animation: fadeIn 0.12s ease both; }
|
||||
.markers-panel-drawer { width: 280px; max-width: 90vw; background: var(--bg-surface); border-right: 1px solid var(--border-base); box-shadow: 4px 0 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
||||
@keyframes drawerIn { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_ALL_TRACKER_RECORDS,
|
||||
UPDATE_TRACK,
|
||||
UNBIND_TRACK,
|
||||
FETCH_TRACK,
|
||||
} from "../../lib/queries";
|
||||
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Tracker, TrackRecord } from "../../lib/types";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TrackerWithRecords extends Tracker {
|
||||
trackRecords: { nodes: TrackRecord[] };
|
||||
}
|
||||
|
||||
interface FlatRecord extends TrackRecord {
|
||||
tracker: Tracker;
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let trackers: TrackerWithRecords[] = $state([]);
|
||||
let loading: boolean = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Filter/view state
|
||||
let activeTrackerId: number | "all" = $state("all");
|
||||
let statusFilter: number | "all" = $state("all");
|
||||
let searchQuery: string = $state("");
|
||||
let sortBy: "title" | "status" | "score" | "progress" = $state("title");
|
||||
|
||||
// Mutation state
|
||||
let updatingId: number | null = $state(null);
|
||||
let syncingId: number | null = $state(null);
|
||||
// Chapter editing: recordId → draft value
|
||||
let editingChapter: number | null = $state(null);
|
||||
let chapterDraft: number = $state(0);
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function load() {
|
||||
loading = true; error = null;
|
||||
try {
|
||||
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
|
||||
trackers = res.trackers.nodes;
|
||||
} catch (e: any) {
|
||||
error = e?.message ?? "Failed to load tracking data";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
// ── Derived data ───────────────────────────────────────────────────────────
|
||||
|
||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||
|
||||
const allRecords: FlatRecord[] = $derived(
|
||||
loggedInTrackers.flatMap(t =>
|
||||
t.trackRecords.nodes.map(r => ({
|
||||
...r,
|
||||
trackerId: r.trackerId ?? t.id, // fallback in case field is missing
|
||||
tracker: t as Tracker,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
const totalCount = $derived(allRecords.length);
|
||||
|
||||
// Status options across active tracker
|
||||
const statusOptions = $derived.by(() => {
|
||||
if (activeTrackerId === "all") {
|
||||
// Merge all statuses, dedupe by value+name
|
||||
const seen = new Map<string, { value: number; name: string }>();
|
||||
for (const t of loggedInTrackers) {
|
||||
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
|
||||
});
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
let list = activeTrackerId === "all"
|
||||
? allRecords
|
||||
: allRecords.filter(r => Number(r.trackerId) === Number(activeTrackerId));
|
||||
|
||||
if (statusFilter !== "all")
|
||||
list = list.filter(r => Number(r.status) === Number(statusFilter));
|
||||
|
||||
if (searchQuery.trim())
|
||||
list = list.filter(r =>
|
||||
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
r.manga?.title?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return [...list].sort((a, b) => {
|
||||
if (sortBy === "title") return a.title.localeCompare(b.title);
|
||||
if (sortBy === "status") return a.status - b.status;
|
||||
if (sortBy === "score") return parseFloat(b.displayScore ?? "0") - parseFloat(a.displayScore ?? "0");
|
||||
if (sortBy === "progress") {
|
||||
const ap = a.totalChapters > 0 ? a.lastChapterRead / a.totalChapters : 0;
|
||||
const bp = b.totalChapters > 0 ? b.lastChapterRead / b.totalChapters : 0;
|
||||
return bp - ap;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function updateStatus(record: FlatRecord, status: number) {
|
||||
updatingId = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, status }
|
||||
);
|
||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateScore(record: FlatRecord, scoreString: string) {
|
||||
updatingId = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, scoreString }
|
||||
);
|
||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRecord(record: FlatRecord) {
|
||||
syncingId = record.id;
|
||||
try {
|
||||
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
||||
FETCH_TRACK, { recordId: record.id }
|
||||
);
|
||||
patchRecord(record.trackerId, res.fetchTrack.trackRecord);
|
||||
addToast({ kind: "success", title: "Synced from tracker" });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||
} finally {
|
||||
syncingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function unbind(record: FlatRecord) {
|
||||
updatingId = record.id;
|
||||
try {
|
||||
await gql(UNBIND_TRACK, { recordId: record.id });
|
||||
trackers = trackers.map(t =>
|
||||
t.id !== record.trackerId ? t : {
|
||||
...t,
|
||||
trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== record.id) }
|
||||
}
|
||||
);
|
||||
addToast({ kind: "info", title: "Unlinked from " + record.tracker.name });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
|
||||
trackers = trackers.map(t =>
|
||||
t.id !== trackerId ? t : {
|
||||
...t,
|
||||
trackRecords: {
|
||||
nodes: t.trackRecords.nodes.map(r => r.id === updated.id ? { ...r, ...updated } : r)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function openManga(record: FlatRecord) {
|
||||
if (!record.manga) return;
|
||||
setActiveManga(record.manga as any);
|
||||
setNavPage("library");
|
||||
}
|
||||
|
||||
function openChapterEditor(record: FlatRecord) {
|
||||
editingChapter = record.id;
|
||||
chapterDraft = record.lastChapterRead;
|
||||
}
|
||||
|
||||
function cancelChapterEditor() {
|
||||
editingChapter = null;
|
||||
}
|
||||
|
||||
async function submitChapter(record: FlatRecord) {
|
||||
const val = Math.max(0, chapterDraft);
|
||||
editingChapter = null;
|
||||
if (val === record.lastChapterRead) return;
|
||||
updatingId = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }
|
||||
);
|
||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<h1 class="heading">Tracking</h1>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh all">
|
||||
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracker filter tabs -->
|
||||
{#if !loading && loggedInTrackers.length > 0}
|
||||
<div class="tracker-tabs">
|
||||
<button
|
||||
class="tracker-tab"
|
||||
class:tab-active={activeTrackerId === "all"}
|
||||
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
|
||||
>
|
||||
All
|
||||
<span class="tab-count">{totalCount}</span>
|
||||
</button>
|
||||
{#each loggedInTrackers as t}
|
||||
{@const count = t.trackRecords.nodes.length}
|
||||
<button
|
||||
class="tracker-tab"
|
||||
class:tab-active={activeTrackerId === t.id}
|
||||
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
|
||||
>
|
||||
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-tracker-icon" />
|
||||
{t.name}
|
||||
<span class="tab-count">{count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Filter + sort bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} weight="light" class="search-ico" />
|
||||
<input
|
||||
class="filter-search"
|
||||
placeholder="Search titles…"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-right">
|
||||
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<select class="filter-select" bind:value={statusFilter}
|
||||
onchange={(e) => {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
statusFilter = v === "all" ? "all" : parseInt(v);
|
||||
}}>
|
||||
<option value="all">All statuses</option>
|
||||
{#each statusOptions as s}
|
||||
<option value={s.value}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select class="filter-select" bind:value={sortBy}>
|
||||
<option value="title">Sort: Title</option>
|
||||
<option value="status">Sort: Status</option>
|
||||
<option value="score">Sort: Score</option>
|
||||
<option value="progress">Sort: Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Body ────────────────────────────────────────────────────────────── -->
|
||||
<div class="page-body">
|
||||
|
||||
{#if loading}
|
||||
<div class="state-center">
|
||||
<CircleNotch size={22} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
<span class="state-label">Loading tracking data…</span>
|
||||
</div>
|
||||
|
||||
{:else if error}
|
||||
<div class="state-center">
|
||||
<p class="state-error">{error}</p>
|
||||
<button class="retry-btn" onclick={load}>Retry</button>
|
||||
</div>
|
||||
|
||||
{:else if loggedInTrackers.length === 0}
|
||||
<div class="state-center">
|
||||
<p class="state-text">No trackers connected.</p>
|
||||
<p class="state-hint">Go to Settings → Tracking to log in to AniList, MAL, or others.</p>
|
||||
</div>
|
||||
|
||||
{:else if filtered.length === 0}
|
||||
<div class="state-center">
|
||||
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results match your filters." : "Nothing tracked yet."}</p>
|
||||
{#if searchQuery || statusFilter !== "all"}
|
||||
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="records-list">
|
||||
{#each filtered as record (record.tracker.id + ":" + record.id)}
|
||||
{@const tracker = record.tracker}
|
||||
{@const isBusy = updatingId === record.id}
|
||||
{@const isSyncing = syncingId === record.id}
|
||||
{@const progress = record.totalChapters > 0
|
||||
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
|
||||
: null}
|
||||
|
||||
<div class="record-card" class:record-busy={isBusy}>
|
||||
|
||||
<!-- Cover -->
|
||||
<div class="record-cover-wrap" role="button" tabindex="0"
|
||||
onclick={() => openManga(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||
>
|
||||
{#if record.manga?.thumbnailUrl}
|
||||
<img src={thumbUrl(record.manga.thumbnailUrl)} alt={record.title} class="record-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="record-cover record-cover-empty"></div>
|
||||
{/if}
|
||||
<!-- Tracker badge -->
|
||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-badge" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="record-body">
|
||||
<div class="record-top">
|
||||
<div class="record-titles" role="button" tabindex="0"
|
||||
onclick={() => openManga(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||
>
|
||||
<span class="record-title">{record.title}</span>
|
||||
{#if record.manga?.title && record.manga.title !== record.title}
|
||||
<span class="record-local-title">{record.manga.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="record-header-actions">
|
||||
{#if activeTrackerId === "all"}
|
||||
<span class="record-tracker-label">
|
||||
<img src={thumbUrl(record.tracker.icon)} alt={record.tracker.name} class="record-tracker-label-icon" />
|
||||
{record.tracker.name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if isSyncing}
|
||||
<CircleNotch size={12} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else}
|
||||
<button class="card-icon-btn" title="Sync from tracker" onclick={() => syncRecord(record)}>
|
||||
<ArrowsClockwise size={12} weight="light" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if record.remoteUrl}
|
||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-icon-btn" title="Open on {record.tracker.name}">
|
||||
<ArrowSquareOut size={12} weight="light" />
|
||||
</a>
|
||||
{/if}
|
||||
<button class="card-icon-btn danger" title="Unlink" onclick={() => unbind(record)} disabled={isBusy}>
|
||||
<X size={12} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="record-controls">
|
||||
<select
|
||||
class="record-select"
|
||||
value={record.status}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
||||
>
|
||||
{#each (tracker.statuses ?? []) as s}
|
||||
<option value={s.value}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="record-select record-select-score"
|
||||
value={record.displayScore}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{#each (tracker.scores ?? []) as s}
|
||||
<option value={s}>★ {s}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if record.private}
|
||||
<span class="private-badge" title="Private"><Lock size={10} weight="fill" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Progress / Chapter editor -->
|
||||
{#if editingChapter === record.id}
|
||||
<div class="chapter-editor">
|
||||
<div class="chapter-editor-top">
|
||||
<span class="chapter-editor-label">Chapter read</span>
|
||||
<div class="chapter-input-wrap">
|
||||
<input
|
||||
type="number"
|
||||
class="chapter-input"
|
||||
min="0"
|
||||
max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||
step="0.5"
|
||||
bind:value={chapterDraft}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") submitChapter(record);
|
||||
if (e.key === "Escape") cancelChapterEditor();
|
||||
}}
|
||||
use:focusEl
|
||||
/>
|
||||
{#if record.totalChapters > 0}
|
||||
<span class="chapter-total">/ {record.totalChapters}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if record.totalChapters > 0}
|
||||
<input
|
||||
type="range"
|
||||
class="chapter-slider"
|
||||
min="0"
|
||||
max={record.totalChapters}
|
||||
step="1"
|
||||
bind:value={chapterDraft}
|
||||
/>
|
||||
{/if}
|
||||
<div class="chapter-editor-actions">
|
||||
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if progress !== null}
|
||||
<div class="record-progress clickable" role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to edit"
|
||||
>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width:{progress}%"></div>
|
||||
</div>
|
||||
<span class="progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
|
||||
<span class="progress-edit-hint">✎</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="record-progress clickable no-total" role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to set chapter"
|
||||
>
|
||||
<span class="progress-label">
|
||||
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"}
|
||||
</span>
|
||||
<span class="progress-edit-hint">✎</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); }
|
||||
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: none; color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Tracker tabs ───────────────────────────────────────────────────────── */
|
||||
.tracker-tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none; }
|
||||
.tracker-tabs::-webkit-scrollbar { display: none; }
|
||||
.tracker-tab {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 9px 10px 8px;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint); background: none; border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0; cursor: pointer; white-space: nowrap;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tracker-tab:hover { color: var(--text-muted); }
|
||||
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
|
||||
.tab-tracker-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
|
||||
.tab-count { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
|
||||
.tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
/* ── Filter bar ─────────────────────────────────────────────────────────── */
|
||||
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); }
|
||||
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px 10px; }
|
||||
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
||||
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||
.filter-search::placeholder { color: var(--text-faint); }
|
||||
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
.filter-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 24px 4px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||
color: var(--text-faint); outline: none; cursor: pointer;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 7px center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
||||
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
|
||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
||||
|
||||
/* ── States ─────────────────────────────────────────────────────────────── */
|
||||
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; }
|
||||
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
||||
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
|
||||
/* ── Records list ───────────────────────────────────────────────────────── */
|
||||
.records-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.record-card {
|
||||
display: flex; align-items: flex-start; gap: var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
transition: background var(--t-fast), opacity var(--t-base);
|
||||
}
|
||||
.record-card:hover { background: var(--bg-raised); }
|
||||
.record-busy { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
/* Cover */
|
||||
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; }
|
||||
.record-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; transition: opacity var(--t-fast); }
|
||||
.record-cover-empty { background: var(--bg-overlay); }
|
||||
.record-cover-wrap:hover .record-cover { opacity: 0.75; }
|
||||
.record-tracker-badge { position: absolute; bottom: -3px; right: -3px; width: 14px; height: 14px; border-radius: 2px; border: 1px solid var(--bg-base); object-fit: contain; background: var(--bg-raised); }
|
||||
|
||||
/* Body */
|
||||
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.record-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); }
|
||||
.record-titles { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; cursor: pointer; }
|
||||
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
|
||||
.record-titles:hover .record-title { color: var(--accent-fg); }
|
||||
.record-local-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.record-header-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
|
||||
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
|
||||
.record-tracker-label-icon { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
|
||||
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
|
||||
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.card-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* Controls */
|
||||
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.record-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 22px 3px 7px; border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent; background: var(--bg-overlay);
|
||||
color: var(--text-faint); outline: none; cursor: pointer;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 6px center;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.record-select:disabled { opacity: 0.35; cursor: default; }
|
||||
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
.record-select-score { max-width: 86px; }
|
||||
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
|
||||
|
||||
/* Progress */
|
||||
.record-progress { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.progress-track { flex: 1; height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; max-width: 140px; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); }
|
||||
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
||||
.record-progress.clickable:hover .progress-label { color: var(--text-muted); }
|
||||
.progress-edit-hint { font-size: 10px; color: var(--text-faint); opacity: 0; transition: opacity var(--t-fast); }
|
||||
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
|
||||
|
||||
/* Chapter editor */
|
||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); }
|
||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.chapter-input { width: 72px; background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
|
||||
.chapter-input:focus { border-color: var(--accent); }
|
||||
.chapter-input::-webkit-outer-spin-button,
|
||||
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||
.chapter-save-btn:hover { filter: brightness(1.15); }
|
||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
|
||||
.chapter-cancel-btn:hover { color: var(--text-muted); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||
</script>
|
||||
@@ -0,0 +1,575 @@
|
||||
<script lang="ts">
|
||||
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
|
||||
import {
|
||||
store, updateSettings, saveCustomTheme, deleteCustomTheme,
|
||||
type CustomTheme, type ThemeTokens, DEFAULT_THEME_TOKENS,
|
||||
} from "../../store/state.svelte";
|
||||
|
||||
interface Props {
|
||||
editingId?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { editingId = $bindable(null), onClose }: Props = $props();
|
||||
|
||||
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
|
||||
{
|
||||
label: "Backgrounds",
|
||||
tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"],
|
||||
},
|
||||
{
|
||||
label: "Borders",
|
||||
tokens: ["border-dim", "border-base", "border-strong", "border-focus"],
|
||||
},
|
||||
{
|
||||
label: "Text",
|
||||
tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"],
|
||||
},
|
||||
{
|
||||
label: "Accent",
|
||||
tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"],
|
||||
},
|
||||
{
|
||||
label: "Semantic",
|
||||
tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"],
|
||||
},
|
||||
];
|
||||
|
||||
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
|
||||
"bg-void": "Void (deepest bg)",
|
||||
"bg-base": "Base",
|
||||
"bg-surface": "Surface",
|
||||
"bg-raised": "Raised",
|
||||
"bg-overlay": "Overlay",
|
||||
"bg-subtle": "Subtle",
|
||||
"border-dim": "Dim border",
|
||||
"border-base": "Base border",
|
||||
"border-strong": "Strong border",
|
||||
"border-focus": "Focus ring",
|
||||
"text-primary": "Primary text",
|
||||
"text-secondary": "Secondary text",
|
||||
"text-muted": "Muted text",
|
||||
"text-faint": "Faint text",
|
||||
"text-disabled": "Disabled text",
|
||||
"accent": "Accent",
|
||||
"accent-dim": "Accent dim",
|
||||
"accent-muted": "Accent muted",
|
||||
"accent-fg": "Accent foreground",
|
||||
"accent-bright": "Accent bright",
|
||||
"color-error": "Error",
|
||||
"color-error-bg": "Error background",
|
||||
"color-success": "Success",
|
||||
"color-info": "Info",
|
||||
"color-info-bg": "Info background",
|
||||
};
|
||||
|
||||
function loadInitial(): { name: string; tokens: ThemeTokens } {
|
||||
if (editingId) {
|
||||
const existing = store.settings.customThemes.find(t => t.id === editingId);
|
||||
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
|
||||
}
|
||||
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
|
||||
}
|
||||
|
||||
const initial = loadInitial();
|
||||
let themeName: string = $state(initial.name);
|
||||
let tokens: ThemeTokens = $state(initial.tokens);
|
||||
let saveStatus: "idle" | "saved" = $state("idle");
|
||||
let importError: string | null = $state(null);
|
||||
|
||||
function toCssVars(t: ThemeTokens): string {
|
||||
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const name = themeName.trim() || "Untitled Theme";
|
||||
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
|
||||
const theme: CustomTheme = { id, name, tokens: { ...tokens } };
|
||||
saveCustomTheme(theme);
|
||||
updateSettings({ theme: id });
|
||||
editingId = id;
|
||||
saveStatus = "saved";
|
||||
setTimeout(() => (saveStatus = "idle"), 1800);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!editingId) { onClose(); return; }
|
||||
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
|
||||
deleteCustomTheme(editingId);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const data: CustomTheme = {
|
||||
id: editingId ?? "custom:export",
|
||||
name: themeName.trim() || "Untitled Theme",
|
||||
tokens: { ...tokens },
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "file";
|
||||
inp.accept = ".json";
|
||||
inp.onchange = async () => {
|
||||
const file = inp.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
|
||||
if (typeof data.name === "string") themeName = data.name;
|
||||
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
|
||||
importError = null;
|
||||
} catch (e: any) {
|
||||
importError = e.message ?? "Could not parse theme file";
|
||||
setTimeout(() => (importError = null), 3000);
|
||||
}
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
tokens = { ...DEFAULT_THEME_TOKENS };
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<div class="te-backdrop" tabindex="-1" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
|
||||
<div
|
||||
class="te-shell"
|
||||
role="dialog"
|
||||
aria-label="Theme editor"
|
||||
tabindex="0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
<header class="te-header">
|
||||
<div class="te-header-left">
|
||||
<button class="te-icon-btn" onclick={onClose} title="Close editor">
|
||||
<ArrowLeft size={14} weight="bold" />
|
||||
</button>
|
||||
<input
|
||||
bind:value={themeName}
|
||||
class="te-name-input"
|
||||
placeholder="Theme name"
|
||||
maxlength={40}
|
||||
spellcheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="te-header-actions">
|
||||
{#if importError}
|
||||
<span class="te-import-err">{importError}</span>
|
||||
{/if}
|
||||
<button class="te-action-btn" onclick={handleImport} title="Import from JSON">
|
||||
<UploadSimple size={13} />
|
||||
<span>Import</span>
|
||||
</button>
|
||||
<button class="te-action-btn" onclick={handleExport} title="Export as JSON">
|
||||
<DownloadSimple size={13} />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
<button class="te-action-btn te-ghost" onclick={resetToDefaults} title="Reset all to dark defaults">
|
||||
Reset
|
||||
</button>
|
||||
{#if editingId}
|
||||
<button class="te-action-btn te-danger" onclick={handleDelete} title="Delete theme">
|
||||
<Trash size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
<button class="te-save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
|
||||
<FloppyDisk size={13} />
|
||||
<span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
|
||||
</button>
|
||||
<button class="te-icon-btn" onclick={onClose} title="Close">
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="te-body">
|
||||
|
||||
<aside class="te-preview-pane">
|
||||
<div class="te-pane-label">Live Preview</div>
|
||||
|
||||
<div class="te-preview-ui" style={toCssVars(tokens)}>
|
||||
<div class="prv-sidebar">
|
||||
{#each [true, false, false, false] as active}
|
||||
<div class="prv-sb-dot" class:active></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="prv-main">
|
||||
<div class="prv-titlebar">
|
||||
<div class="prv-win-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="prv-win-title">Moku</div>
|
||||
</div>
|
||||
<div class="prv-content">
|
||||
<div class="prv-row">
|
||||
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
|
||||
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
|
||||
</div>
|
||||
<div class="prv-grid">
|
||||
{#each Array(6) as _, i}
|
||||
<div class="prv-card" class:active-card={i === 0}>
|
||||
<div class="prv-cover"></div>
|
||||
<div class="prv-card-line"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="prv-reader">
|
||||
<div class="prv-page"></div>
|
||||
</div>
|
||||
<div class="prv-toast">
|
||||
<div class="prv-toast-dot"></div>
|
||||
<div class="prv-toast-lines">
|
||||
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
|
||||
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="te-swatches" style={toCssVars(tokens)}>
|
||||
{#each [
|
||||
["bg-base","bg-base"],["bg-surface","bg-surface"],
|
||||
["accent","accent"],["accent-fg","accent-fg"],
|
||||
["text-primary","text-primary"],["text-muted","text-muted"],
|
||||
["color-error","color-error"],
|
||||
] as [varName, label]}
|
||||
<div
|
||||
class="te-swatch"
|
||||
style="background: var(--{varName})"
|
||||
title={label}
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="te-editor-pane">
|
||||
{#each TOKEN_GROUPS as group}
|
||||
<div class="te-group">
|
||||
<div class="te-group-label">{group.label}</div>
|
||||
<div class="te-token-list">
|
||||
{#each group.tokens as token}
|
||||
<div class="te-token-row">
|
||||
<label class="te-color-swatch" style="background: {tokens[token]}" title="Pick colour">
|
||||
<input
|
||||
type="color"
|
||||
class="te-color-picker"
|
||||
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0,7)}
|
||||
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
|
||||
/>
|
||||
</label>
|
||||
<span class="te-token-name">{TOKEN_LABELS[token]}</span>
|
||||
<span class="te-token-key">{token}</span>
|
||||
<input
|
||||
type="text"
|
||||
class="te-hex-input"
|
||||
value={tokens[token]}
|
||||
spellcheck={false}
|
||||
oninput={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
|
||||
}}
|
||||
onblur={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
||||
(e.target as HTMLInputElement).value = tokens[token];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.te-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
z-index: 200;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: teBackdropIn 0.14s ease both;
|
||||
}
|
||||
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.te-shell {
|
||||
width: calc(100% - 48px);
|
||||
max-width: 1100px;
|
||||
height: calc(100% - 48px);
|
||||
max-height: 760px;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 10px;
|
||||
animation: teShellIn 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
overflow: hidden;
|
||||
}
|
||||
@keyframes teShellIn {
|
||||
from { transform: translateY(10px) scale(0.99); opacity: 0; }
|
||||
to { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.te-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; padding: 0 16px; height: 46px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
background: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.te-header-left {
|
||||
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
|
||||
}
|
||||
|
||||
.te-icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; border-radius: 5px;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.1s, background 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.te-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
.te-name-input {
|
||||
flex: 1; min-width: 0;
|
||||
background: none; border: none; outline: none;
|
||||
font-family: var(--font-sans); font-size: 13px; font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 3px 0;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.te-name-input:focus { border-color: var(--border-focus); }
|
||||
.te-name-input::placeholder { color: var(--text-faint); }
|
||||
|
||||
.te-header-actions {
|
||||
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.te-import-err {
|
||||
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em;
|
||||
color: var(--color-error); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.te-action-btn {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
|
||||
padding: 4px 10px; border-radius: 4px;
|
||||
border: 1px solid var(--border-dim);
|
||||
background: none; color: var(--text-muted);
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: color 0.1s, border-color 0.1s, background 0.1s;
|
||||
}
|
||||
.te-action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
|
||||
.te-ghost { border-color: transparent; }
|
||||
.te-ghost:hover { border-color: var(--border-dim); }
|
||||
|
||||
.te-danger { color: var(--color-error); border-color: transparent; }
|
||||
.te-danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
|
||||
|
||||
.te-save-btn {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
|
||||
padding: 5px 14px; border-radius: 4px;
|
||||
border: 1px solid var(--accent-dim);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: filter 0.1s, background 0.12s;
|
||||
}
|
||||
.te-save-btn:hover { filter: brightness(1.12); }
|
||||
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
|
||||
|
||||
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
|
||||
|
||||
.te-preview-pane {
|
||||
width: 260px; flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-dim);
|
||||
background: var(--bg-void);
|
||||
display: flex; flex-direction: column;
|
||||
padding: 16px; gap: 12px;
|
||||
}
|
||||
|
||||
.te-pane-label {
|
||||
font-family: var(--font-ui); font-size: 10px;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.te-preview-ui {
|
||||
flex: 1; min-height: 0;
|
||||
border-radius: 8px; overflow: hidden;
|
||||
border: 1px solid var(--border-base);
|
||||
display: flex; background: var(--bg-void);
|
||||
}
|
||||
|
||||
.prv-sidebar {
|
||||
width: 34px; flex-shrink: 0;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-dim);
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; padding: 12px 0; gap: 9px;
|
||||
}
|
||||
.prv-sb-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--text-faint); opacity: 0.4;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
|
||||
|
||||
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
.prv-titlebar {
|
||||
height: 26px; flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
display: flex; align-items: center; padding: 0 8px; gap: 7px;
|
||||
}
|
||||
.prv-win-dots { display: flex; gap: 4px; }
|
||||
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
|
||||
.prv-win-title { font-family: var(--font-ui); font-size: 9px; letter-spacing: 0.1em; color: var(--text-faint); }
|
||||
|
||||
.prv-content {
|
||||
flex: 1; overflow: hidden;
|
||||
padding: 8px; display: flex; flex-direction: column; gap: 7px;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.prv-row { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||
.prv-bar { height: 3px; border-radius: 2px; }
|
||||
|
||||
.prv-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; flex-shrink: 0;
|
||||
}
|
||||
.prv-card {
|
||||
border-radius: 4px; border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised); overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.prv-card.active-card { border-color: var(--accent); }
|
||||
.prv-cover { height: 34px; background: var(--bg-overlay); }
|
||||
.prv-card-line { height: 3px; margin: 4px 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
|
||||
|
||||
.prv-reader {
|
||||
flex: 1; min-height: 0;
|
||||
border-radius: 4px; border: 1px solid var(--border-dim);
|
||||
background: var(--bg-overlay);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
|
||||
|
||||
.prv-toast {
|
||||
flex-shrink: 0;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 8px; border-radius: 5px;
|
||||
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
|
||||
}
|
||||
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||
.prv-toast-lines { flex: 1; }
|
||||
|
||||
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
.te-swatch {
|
||||
width: 22px; height: 22px; border-radius: 4px;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
flex-shrink: 0; cursor: default;
|
||||
}
|
||||
|
||||
.te-editor-pane {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex; flex-direction: column; gap: 22px;
|
||||
}
|
||||
.te-editor-pane::-webkit-scrollbar { width: 4px; }
|
||||
.te-editor-pane::-webkit-scrollbar-track { background: transparent; }
|
||||
.te-editor-pane::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong); border-radius: 9999px;
|
||||
}
|
||||
|
||||
.te-group { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.te-group-label {
|
||||
font-family: var(--font-ui); font-size: 10px;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
padding-bottom: 7px; margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.te-token-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.te-token-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 5px 8px; border-radius: 5px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.te-token-row:hover { background: var(--bg-raised); }
|
||||
|
||||
.te-color-swatch {
|
||||
width: 36px; height: 18px; border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
.te-color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
|
||||
|
||||
.te-color-picker {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 0; border: none;
|
||||
}
|
||||
|
||||
.te-token-name {
|
||||
flex: 1; font-size: 12px; color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.te-token-key {
|
||||
font-family: var(--font-ui); font-size: 10px;
|
||||
letter-spacing: 0.05em; color: var(--text-faint);
|
||||
flex-shrink: 0; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.te-hex-input {
|
||||
width: 82px; flex-shrink: 0;
|
||||
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 3px; padding: 3px 7px;
|
||||
outline: none;
|
||||
transition: border-color 0.1s, color 0.1s;
|
||||
}
|
||||
.te-hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,309 @@
|
||||
<script lang="ts">
|
||||
import { X } from "phosphor-svelte";
|
||||
import { store, updateSettings } from "../../store/state.svelte";
|
||||
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||
import type { MangaPrefs } from "../../store/state.svelte";
|
||||
import type { Chapter } from "../../lib/types";
|
||||
|
||||
let { mangaId, chapters, onClose }: {
|
||||
mangaId: number;
|
||||
chapters: Chapter[];
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
const mangaPrefs = $derived(
|
||||
(store.settings.mangaPrefs?.[mangaId] ?? {}) as Partial<MangaPrefs>
|
||||
);
|
||||
|
||||
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
|
||||
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
|
||||
}
|
||||
|
||||
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
|
||||
updateSettings({
|
||||
mangaPrefs: {
|
||||
...store.settings.mangaPrefs,
|
||||
[mangaId]: { ...(store.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const scanlators = $derived(
|
||||
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
);
|
||||
|
||||
const DOWNLOAD_AHEAD_OPTIONS = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 10, label: "10" },
|
||||
];
|
||||
|
||||
const MAX_KEEP_OPTIONS = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 10, label: "10" },
|
||||
{ value: 25, label: "25" },
|
||||
];
|
||||
|
||||
const DELETE_DELAY_OPTIONS = [
|
||||
{ value: 0, label: "Now" },
|
||||
{ value: 24, label: "1 day" },
|
||||
{ value: 168, label: "1 week" },
|
||||
];
|
||||
|
||||
const REFRESH_INTERVAL_OPTIONS = [
|
||||
{ value: "global", label: "Default" },
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
];
|
||||
|
||||
function onBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<span class="modal-title">Automation</span>
|
||||
<span class="modal-subtitle">Per-series rules</span>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<p class="section-label">Downloads</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Auto-download new chapters</span>
|
||||
<span class="auto-desc">Queue new chapters when this series refreshes</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={getPref("autoDownload")}
|
||||
aria-label="Auto-download new chapters"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={getPref("autoDownload")}
|
||||
onclick={() => setPref("autoDownload", !getPref("autoDownload"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Download ahead</span>
|
||||
<span class="auto-desc">Pre-fetch chapters while reading</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={getPref("downloadAhead") === opt.value}
|
||||
onclick={() => setPref("downloadAhead", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Max chapters to keep</span>
|
||||
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each MAX_KEEP_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={getPref("maxKeepChapters") === opt.value}
|
||||
onclick={() => setPref("maxKeepChapters", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">On Read</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Delete after reading</span>
|
||||
<span class="auto-desc">Remove download when chapter is marked read</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={getPref("deleteOnRead")}
|
||||
aria-label="Delete after reading"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={getPref("deleteOnRead")}
|
||||
onclick={() => setPref("deleteOnRead", !getPref("deleteOnRead"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
{#if getPref("deleteOnRead")}
|
||||
<div class="auto-row auto-row-sub">
|
||||
<span class="auto-label">Delete delay</span>
|
||||
<div class="auto-chip-group">
|
||||
{#each DELETE_DELAY_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={getPref("deleteDelayHours") === opt.value}
|
||||
onclick={() => setPref("deleteDelayHours", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">Updates</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Pause updates</span>
|
||||
<span class="auto-desc">Skip this series during global refresh</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={getPref("pauseUpdates")}
|
||||
aria-label="Pause updates"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={getPref("pauseUpdates")}
|
||||
onclick={() => setPref("pauseUpdates", !getPref("pauseUpdates"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Refresh interval</span>
|
||||
<span class="auto-desc">How often to check for new chapters</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={getPref("refreshInterval") === opt.value}
|
||||
onclick={() => setPref("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if scanlators.length > 1}
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">Scanlator</p>
|
||||
|
||||
<div class="auto-row auto-row-align-start">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Preferred scanlator</span>
|
||||
<span class="auto-desc">Prioritise this group's chapters in the list</span>
|
||||
</div>
|
||||
<div class="scanlator-list">
|
||||
<button
|
||||
class="auto-chip scanlator-chip"
|
||||
class:auto-chip-on={!getPref("preferredScanlator")}
|
||||
onclick={() => setPref("preferredScanlator", "")}
|
||||
>Any</button>
|
||||
{#each scanlators as s}
|
||||
<button
|
||||
class="auto-chip scanlator-chip"
|
||||
class:auto-chip-on={getPref("preferredScanlator") === s}
|
||||
onclick={() => setPref("preferredScanlator", getPref("preferredScanlator") === s ? "" : s)}
|
||||
title={s}
|
||||
>{s}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; z-index: 300;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 420px; max-width: calc(100vw - var(--sp-6));
|
||||
max-height: 80vh;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl); overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.15s ease both;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.header-left { display: flex; flex-direction: column; gap: 2px; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
/* Body */
|
||||
.modal-body {
|
||||
flex: 1; overflow-y: auto; scrollbar-width: none;
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
}
|
||||
.modal-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Section labels */
|
||||
.section-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-widest); color: var(--text-faint);
|
||||
text-transform: uppercase; margin: 0;
|
||||
}
|
||||
|
||||
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||
|
||||
/* Rows — mirrors SeriesDetail auto-row */
|
||||
.auto-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.auto-row-align-start { align-items: flex-start; }
|
||||
.auto-row-sub {
|
||||
padding-left: var(--sp-3);
|
||||
border-left: 2px solid var(--border-dim);
|
||||
}
|
||||
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
||||
|
||||
/* Toggle */
|
||||
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
|
||||
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
|
||||
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
|
||||
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
|
||||
|
||||
/* Chips */
|
||||
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
/* Scanlator list */
|
||||
.scanlator-list { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; justify-content: flex-end; max-width: 220px; }
|
||||
.scanlator-chip { max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
@@ -2,11 +2,11 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { GET_ALL_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { store, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import { store, openReader, addToast, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
|
||||
let manga: Manga | null = $state(null);
|
||||
let chapters: Chapter[] = $state([]);
|
||||
@@ -17,6 +17,9 @@
|
||||
let folderOpen = $state(false);
|
||||
let newFolderName = $state("");
|
||||
let creatingFolder = $state(false);
|
||||
let allCategories: Category[] = $state([]);
|
||||
let mangaCategories: Category[] = $state([]);
|
||||
let catsLoading: boolean = $state(false);
|
||||
let queueingAll = $state(false);
|
||||
let fetchError: string|null = $state(null);
|
||||
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
||||
@@ -79,7 +82,7 @@
|
||||
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
|
||||
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
|
||||
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
|
||||
const assignedFolders = $derived(store.previewManga ? store.settings.folders.filter((f) => f.mangaIds.includes(store.previewManga!.id)) : []);
|
||||
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
||||
|
||||
const continueChapter = $derived.by(() => {
|
||||
if (!chapters.length) return null;
|
||||
@@ -90,7 +93,7 @@
|
||||
return { ch: chapters[0], label: "Read again" };
|
||||
});
|
||||
|
||||
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
|
||||
$effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
|
||||
|
||||
async function load(id: number) {
|
||||
detailAbort?.abort(); chapterAbort?.abort();
|
||||
@@ -171,11 +174,55 @@
|
||||
close();
|
||||
}
|
||||
|
||||
function handleFolderCreate() {
|
||||
function loadCategories(mangaId: number) {
|
||||
catsLoading = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => {
|
||||
allCategories = d.categories.nodes.filter(c => c.id !== 0);
|
||||
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { catsLoading = false; });
|
||||
}
|
||||
|
||||
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||
// Sync local mangaCategories state after the mutation
|
||||
if (chaps.length) {
|
||||
const allRead = chaps.every(c => c.isRead);
|
||||
const completed = allCategories.find(c => c.name === "Completed");
|
||||
if (completed) {
|
||||
const inCompleted = mangaCategories.some(c => c.id === completed.id);
|
||||
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
|
||||
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCategory(cat: Category) {
|
||||
if (!store.previewManga) return;
|
||||
const mangaId = store.previewManga.id;
|
||||
const inCat = mangaCategories.some(c => c.id === cat.id);
|
||||
await gql(UPDATE_MANGA_CATEGORIES, {
|
||||
mangaId,
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
}).catch(console.error);
|
||||
mangaCategories = inCat
|
||||
? mangaCategories.filter(c => c.id !== cat.id)
|
||||
: [...mangaCategories, cat];
|
||||
}
|
||||
|
||||
async function handleFolderCreate() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name || !store.previewManga) return;
|
||||
const id = addFolder(name);
|
||||
assignMangaToFolder(id, store.previewManga.id);
|
||||
try {
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||
const cat = res.createCategory.category;
|
||||
allCategories = [...allCategories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.previewManga.id, addTo: [cat.id], removeFrom: [] });
|
||||
mangaCategories = [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
newFolderName = ""; creatingFolder = false;
|
||||
}
|
||||
|
||||
@@ -225,12 +272,15 @@
|
||||
</button>
|
||||
{#if folderOpen}
|
||||
<div class="folder-menu">
|
||||
{#if store.settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
|
||||
{#each store.settings.folders as f}
|
||||
{@const isIn = store.previewManga ? f.mangaIds.includes(store.previewManga.id) : false}
|
||||
<button class="folder-item" class:folder-item-on={isIn}
|
||||
onclick={() => store.previewManga && (isIn ? removeMangaFromFolder(f.id, store.previewManga.id) : assignMangaToFolder(f.id, store.previewManga.id))}>
|
||||
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
|
||||
{#if catsLoading}
|
||||
<p class="folder-empty">Loading…</p>
|
||||
{:else if allCategories.length === 0 && !creatingFolder}
|
||||
<p class="folder-empty">No folders yet</p>
|
||||
{/if}
|
||||
{#each allCategories as cat}
|
||||
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
|
||||
<button class="folder-item" class:folder-item-on={isIn} onclick={() => toggleCategory(cat)}>
|
||||
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{cat.name}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="folder-divider"></div>
|
||||
@@ -306,7 +356,7 @@
|
||||
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
|
||||
{/if}
|
||||
{#if continueChapter}
|
||||
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}>
|
||||
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters, displayManga); close(); }}>
|
||||
<Play size={12} weight="fill" />{continueChapter.label}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -343,19 +393,18 @@
|
||||
|
||||
{#if !loadingDetail}
|
||||
<div class="meta-table">
|
||||
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
|
||||
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if}
|
||||
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if}
|
||||
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if}
|
||||
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if}
|
||||
{#if !loadingChapters && firstUpload && lastUpload}
|
||||
<div class="meta-row">
|
||||
<span class="meta-key">Published</span>
|
||||
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}</span>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-col">
|
||||
<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel ?? "N/A"}</span></div>
|
||||
<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span></div>
|
||||
<div class="meta-row"><span class="meta-key">Link</span>{#if displayManga?.realUrl}<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a>{:else}<span class="meta-val">N/A</span>{/if}</div>
|
||||
</div>
|
||||
<div class="meta-col">
|
||||
<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
|
||||
<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga?.artist && displayManga.artist !== displayManga.author ? displayManga.artist : (displayManga?.author ?? "N/A")}</span></div>
|
||||
<div class="meta-row"><span class="meta-key">Scanlator</span><span class="meta-val">{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if}
|
||||
{#if displayManga?.realUrl}<div class="meta-row"><span class="meta-key">Link</span><a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a></div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -478,9 +527,11 @@
|
||||
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
|
||||
.meta-col { display: flex; flex-direction: column; }
|
||||
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
||||
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
|
||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
|
||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
||||
.meta-link:hover { opacity: 0.75; }
|
||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
|
||||
import { store, removeMarker, updateMarker, openReader } from "../../store/state.svelte";
|
||||
import type { MarkerEntry, MarkerColor } from "../../store/state.svelte";
|
||||
import type { Chapter } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
mangaId: number;
|
||||
chapters: Chapter[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { mangaId, chapters, onClose }: Props = $props();
|
||||
|
||||
const COLOR_HEX: Record<MarkerColor, string> = {
|
||||
yellow: "#c4a94a",
|
||||
red: "#c47a7a",
|
||||
blue: "#7a9ec4",
|
||||
green: "#7aab7a",
|
||||
purple: "#a07ac4",
|
||||
};
|
||||
|
||||
const markers = $derived(store.getMarkersForManga(mangaId));
|
||||
|
||||
const grouped = $derived.by(() => {
|
||||
const map = new Map<number, MarkerEntry[]>();
|
||||
for (const m of markers) {
|
||||
if (!map.has(m.chapterId)) map.set(m.chapterId, []);
|
||||
map.get(m.chapterId)!.push(m);
|
||||
}
|
||||
const entries = [...map.entries()].map(([chapterId, items]) => ({
|
||||
chapterId,
|
||||
chapterName: items[0].chapterName,
|
||||
items: [...items].sort((a, b) => a.pageNumber - b.pageNumber),
|
||||
}));
|
||||
const chapterOrder = new Map(chapters.map((c, i) => [c.id, i]));
|
||||
entries.sort((a, b) => (chapterOrder.get(a.chapterId) ?? 9999) - (chapterOrder.get(b.chapterId) ?? 9999));
|
||||
return entries;
|
||||
});
|
||||
|
||||
let editingId: string = $state("");
|
||||
let editNote: string = $state("");
|
||||
let editColor: MarkerColor = $state("yellow");
|
||||
|
||||
function startEdit(m: MarkerEntry) {
|
||||
editingId = m.id;
|
||||
editNote = m.note;
|
||||
editColor = m.color;
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
if (!editingId) return;
|
||||
updateMarker(editingId, { note: editNote.trim(), color: editColor });
|
||||
editingId = "";
|
||||
}
|
||||
|
||||
function jumpToMarker(m: MarkerEntry) {
|
||||
const chapter = chapters.find(c => c.id === m.chapterId);
|
||||
if (!chapter) return;
|
||||
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
openReader(chapter, chaptersAsc);
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<MapPin size={13} weight="fill" />
|
||||
<span>Markers</span>
|
||||
{#if markers.length > 0}
|
||||
<span class="count">{markers.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
{#if grouped.length === 0}
|
||||
<div class="empty">
|
||||
<MapPin size={22} weight="light" style="color:var(--text-faint);opacity:0.4" />
|
||||
<p>No markers yet</p>
|
||||
<p class="empty-sub">Mark pages while reading with the marker button or keybind</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each grouped as group}
|
||||
<div class="group">
|
||||
<div class="group-header">
|
||||
<span class="group-name">{group.chapterName}</span>
|
||||
<span class="group-count">{group.items.length}</span>
|
||||
</div>
|
||||
{#each group.items as m (m.id)}
|
||||
<div class="marker-row" class:editing={editingId === m.id}>
|
||||
<div class="marker-dot" style="background:{COLOR_HEX[m.color]}"></div>
|
||||
<div class="marker-body">
|
||||
{#if editingId === m.id}
|
||||
<div class="edit-wrap">
|
||||
<div class="color-row">
|
||||
{#each Object.entries(COLOR_HEX) as [c, hex]}
|
||||
<button
|
||||
class="color-swatch"
|
||||
class:color-active={editColor === c}
|
||||
style="background:{hex}"
|
||||
onclick={() => editColor = c as MarkerColor}
|
||||
title={c}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<textarea
|
||||
class="edit-input"
|
||||
rows={3}
|
||||
bind:value={editNote}
|
||||
placeholder="Add a note…"
|
||||
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); } if (e.key === "Escape") editingId = ""; }}
|
||||
></textarea>
|
||||
<div class="edit-actions">
|
||||
<button class="edit-save" onclick={commitEdit}><Check size={12} weight="bold" /> Save</button>
|
||||
<button class="edit-cancel" onclick={() => editingId = ""}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="marker-jump" onclick={() => jumpToMarker(m)}>
|
||||
<span class="page-label">p.{m.pageNumber}</span>
|
||||
{#if m.note}
|
||||
<span class="marker-note">{m.note}</span>
|
||||
{:else}
|
||||
<span class="marker-note marker-note-empty">No note</span>
|
||||
{/if}
|
||||
<span class="marker-date">{formatDate(m.updatedAt ?? m.createdAt)}</span>
|
||||
</button>
|
||||
<div class="marker-actions">
|
||||
<button class="marker-action-btn" onclick={() => startEdit(m)} title="Edit"><PencilSimple size={11} weight="light" /></button>
|
||||
<button class="marker-action-btn danger" onclick={() => removeMarker(m.id)} title="Delete"><Trash size={11} weight="light" /></button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.panel-title { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
|
||||
.count { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-full); font-size: var(--text-2xs); padding: 0 5px; color: var(--text-faint); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.panel-body { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
|
||||
.empty { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-8) var(--sp-4); text-align: center; }
|
||||
.empty p { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.empty-sub { font-size: var(--text-2xs) !important; opacity: 0.7; max-width: 180px; line-height: var(--leading-snug); }
|
||||
|
||||
.group { display: flex; flex-direction: column; gap: 2px; }
|
||||
.group-header { display: flex; align-items: center; justify-content: space-between; padding: 6px var(--sp-2) 4px; }
|
||||
.group-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; flex-shrink: 0; }
|
||||
|
||||
.marker-row { display: flex; align-items: flex-start; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||
.marker-row:hover { background: var(--bg-raised); }
|
||||
.marker-row.editing { background: var(--bg-raised); }
|
||||
.marker-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
|
||||
|
||||
.marker-body { flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: var(--sp-1); }
|
||||
.marker-jump { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; background: none; border: none; padding: 0; cursor: pointer; }
|
||||
.page-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||||
.marker-note { font-size: var(--text-xs); color: var(--text-secondary); line-height: var(--leading-snug); white-space: pre-wrap; word-break: break-word; }
|
||||
.marker-note-empty { color: var(--text-faint); font-style: italic; }
|
||||
.marker-date { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.marker-actions { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; opacity: 0; transition: opacity var(--t-fast); }
|
||||
.marker-row:hover .marker-actions { opacity: 1; }
|
||||
.marker-action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||
.marker-action-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.marker-action-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
|
||||
.edit-wrap { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.color-row { display: flex; gap: 5px; }
|
||||
.color-swatch { width: 14px; height: 14px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
|
||||
.color-swatch:hover { transform: scale(1.15); }
|
||||
.color-active { border-color: var(--text-primary) !important; }
|
||||
.edit-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base); }
|
||||
.edit-input:focus { border-color: var(--border-focus); }
|
||||
.edit-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.edit-save { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
|
||||
.edit-save:hover { filter: brightness(1.15); }
|
||||
.edit-cancel { padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.edit-cancel:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
</style>
|
||||
@@ -1,33 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { store, addFolder, assignMangaToFolder, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga } from "../../lib/types";
|
||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga, Category } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
||||
|
||||
let mangas: Manga[] = [];
|
||||
let loading = true;
|
||||
let page = 1;
|
||||
let hasNextPage = false;
|
||||
let browseType: BrowseType = "POPULAR";
|
||||
let search = "";
|
||||
let searchInput = "";
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
||||
let mangas: Manga[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let page = $state(1);
|
||||
let hasNextPage = $state(false);
|
||||
let browseType: BrowseType = $state("POPULAR");
|
||||
let search = $state("");
|
||||
let searchInput = $state("");
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
let categories: Category[] = $state([]);
|
||||
let catsLoaded = false;
|
||||
|
||||
async function fetchMangas(type: BrowseType, p: number, q: string) {
|
||||
if (!$store.activeSource) return;
|
||||
if (!store.activeSource) return;
|
||||
loading = true; mangas = [];
|
||||
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA, { source: $store.activeSource.id, type, page: p, query: q || null }
|
||||
FETCH_SOURCE_MANGA, { source: store.activeSource.id, type, page: p, query: q || null }
|
||||
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
|
||||
.catch(console.error)
|
||||
.finally(() => loading = false);
|
||||
}
|
||||
|
||||
$: if ($store.activeSource) fetchMangas(browseType, page, search);
|
||||
$effect(() => { if (store.activeSource) fetchMangas(browseType, page, search); });
|
||||
|
||||
function submitSearch() {
|
||||
search = searchInput.trim();
|
||||
@@ -40,38 +42,58 @@
|
||||
browseType = mode; search = ""; searchInput = ""; page = 1;
|
||||
}
|
||||
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||
if (!catsLoaded) {
|
||||
catsLoaded = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
return [
|
||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
|
||||
.catch(console.error) },
|
||||
...($store.settings.folders.length > 0 ? [
|
||||
...(categories.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...$store.settings.folders.map((f): MenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
...categories.map((cat): MenuEntry => ({
|
||||
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
|
||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (!name?.trim()) return;
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() }).catch(console.error);
|
||||
if (res) {
|
||||
const cat = res.createCategory.category;
|
||||
categories = [...categories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||
}
|
||||
}},
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $store.activeSource}
|
||||
{#if store.activeSource}
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="back" on:click={() => store.activeSource.set(null)}>
|
||||
<button class="back" onclick={() => setActiveSource(null)}>
|
||||
<ArrowLeft size={13} weight="light" /><span>Sources</span>
|
||||
</button>
|
||||
<span class="source-name">{$store.activeSource.displayName}</span>
|
||||
<span class="source-name">{store.activeSource.displayName}</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="tabs">
|
||||
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
|
||||
<button class="tab" class:active={browseType === mode && !search} on:click={() => setMode(mode)}>
|
||||
<button class="tab" class:active={browseType === mode && !search} onclick={() => setMode(mode)}>
|
||||
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -80,7 +102,7 @@
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search source…" bind:value={searchInput}
|
||||
on:keydown={(e) => e.key === "Enter" && submitSearch()} />
|
||||
onkeydown={(e) => e.key === "Enter" && submitSearch()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,8 +117,8 @@
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each mangas as m (m.id)}
|
||||
<button class="card" on:click={() => { store.activeManga.set(m); store.navPage.set("library"); }}
|
||||
on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||
<button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
|
||||
oncontextmenu={(e) => openCtx(e, m)}>
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
|
||||
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
||||
@@ -109,11 +131,11 @@
|
||||
|
||||
{#if !loading && (page > 1 || hasNextPage)}
|
||||
<div class="pagination">
|
||||
<button class="page-btn" on:click={() => page = Math.max(1, page - 1)} disabled={page === 1}>
|
||||
<button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
|
||||
<Prev size={13} weight="light" /> Prev
|
||||
</button>
|
||||
<span class="page-num">{page}</span>
|
||||
<button class="page-btn" on:click={() => page++} disabled={!hasNextPage}>
|
||||
<button class="page-btn" onclick={() => page++} disabled={!hasNextPage}>
|
||||
Next <Next size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -158,4 +180,5 @@
|
||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES } from "../../lib/queries";
|
||||
@@ -11,7 +12,7 @@
|
||||
let search = $state("");
|
||||
let expanded = $state(new Set<string>());
|
||||
|
||||
$effect(() => {
|
||||
onMount(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => { sources = d.sources.nodes; })
|
||||
.catch(console.error)
|
||||
@@ -53,6 +54,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="lang-row">
|
||||
{#each langs as l}
|
||||
<button class="lang-btn" class:active={lang === l} onclick={() => lang = l}>
|
||||
@@ -95,18 +97,20 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div><!-- .content -->
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { padding: var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-5); }
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.lang-row { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-bottom: var(--sp-4); }
|
||||
.lang-row { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.lang-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.lang-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.lang-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_TRACKERS,
|
||||
GET_MANGA_TRACK_RECORDS,
|
||||
SEARCH_TRACKER,
|
||||
BIND_TRACK,
|
||||
UPDATE_TRACK,
|
||||
UNBIND_TRACK,
|
||||
FETCH_TRACK,
|
||||
} from "../../lib/queries";
|
||||
import { addToast } from "../../store/state.svelte";
|
||||
import type { Tracker, TrackRecord, TrackSearch } from "../../lib/types";
|
||||
|
||||
let { mangaId, mangaTitle, onClose }: {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
type TabId = "records" | number;
|
||||
|
||||
let trackers: Tracker[] = $state([]);
|
||||
let records: TrackRecord[] = $state([]);
|
||||
let loading: boolean = $state(true);
|
||||
let activeTab: TabId = $state("records");
|
||||
|
||||
let searchQuery: string = $state("");
|
||||
let searchResults: TrackSearch[] = $state([]);
|
||||
let searching: boolean = $state(false);
|
||||
let searchInited: Set<number> = $state(new Set());
|
||||
|
||||
let binding: boolean = $state(false);
|
||||
let updatingRecord: number | null = $state(null);
|
||||
let syncing: number | null = $state(null);
|
||||
let editingChapter: number | null = $state(null);
|
||||
let chapterDraft: number = $state(0);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const [tRes, rRes] = await Promise.all([
|
||||
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
|
||||
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(
|
||||
GET_MANGA_TRACK_RECORDS, { mangaId }
|
||||
),
|
||||
]);
|
||||
trackers = tRes.trackers.nodes;
|
||||
records = rRes.manga.trackRecords.nodes;
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
$effect(() => {
|
||||
const tab = activeTab;
|
||||
if (typeof tab !== "number") return;
|
||||
if (searchInited.has(tab)) return;
|
||||
searchQuery = mangaTitle;
|
||||
searchInited = new Set([...searchInited, tab]);
|
||||
doSearch(tab, mangaTitle);
|
||||
});
|
||||
|
||||
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
||||
function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); }
|
||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
if (typeof activeTab !== "number") return;
|
||||
const tid = activeTab;
|
||||
if (!searchQuery.trim()) { searchResults = []; return; }
|
||||
searchTimer = setTimeout(() => doSearch(tid, searchQuery), 400);
|
||||
}
|
||||
|
||||
async function doSearch(trackerId: number, query: string) {
|
||||
if (!query.trim()) return;
|
||||
searching = true;
|
||||
searchResults = [];
|
||||
try {
|
||||
const res = await gql<{ searchTracker: { trackSearches: TrackSearch[] } }>(
|
||||
SEARCH_TRACKER, { trackerId, query: query.trim() }
|
||||
);
|
||||
searchResults = res.searchTracker.trackSearches;
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Search failed", body: e?.message });
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function bind(result: TrackSearch) {
|
||||
if (typeof activeTab !== "number") return;
|
||||
binding = true;
|
||||
try {
|
||||
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
|
||||
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
|
||||
);
|
||||
records = [...records.filter(r => r.trackerId !== activeTab), res.bindTrack.trackRecord];
|
||||
addToast({ kind: "success", title: "Now tracking", body: result.title });
|
||||
activeTab = "records";
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to bind", body: e?.message });
|
||||
} finally {
|
||||
binding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unbind(record: TrackRecord) {
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
await gql(UNBIND_TRACK, { recordId: record.id });
|
||||
records = records.filter(r => r.id !== record.id);
|
||||
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
|
||||
} finally {
|
||||
updatingRecord = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(record: TrackRecord, status: number) {
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, status }
|
||||
);
|
||||
patchRecord(res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingRecord = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateScore(record: TrackRecord, scoreString: string) {
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, scoreString }
|
||||
);
|
||||
patchRecord(res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingRecord = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePrivate(record: TrackRecord) {
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, private: !record.private }
|
||||
);
|
||||
patchRecord(res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingRecord = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRecord(record: TrackRecord) {
|
||||
syncing = record.id;
|
||||
try {
|
||||
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
||||
FETCH_TRACK, { recordId: record.id }
|
||||
);
|
||||
patchRecord(res.fetchTrack.trackRecord);
|
||||
addToast({ kind: "success", title: "Synced from tracker" });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||
} finally {
|
||||
syncing = null;
|
||||
}
|
||||
}
|
||||
|
||||
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
|
||||
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
|
||||
}
|
||||
|
||||
function openChapterEditor(record: TrackRecord) {
|
||||
editingChapter = record.id;
|
||||
chapterDraft = record.lastChapterRead;
|
||||
}
|
||||
|
||||
function cancelChapterEditor() { editingChapter = null; }
|
||||
|
||||
async function submitChapter(record: TrackRecord) {
|
||||
const val = Math.max(0, chapterDraft);
|
||||
editingChapter = null;
|
||||
if (val === record.lastChapterRead) return;
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }
|
||||
);
|
||||
patchRecord(res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingRecord = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => e.key === "Escape" && onClose()} />
|
||||
|
||||
<div
|
||||
class="backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="modal" role="dialog" aria-label="Tracking">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<span class="modal-title">Tracking</span>
|
||||
<span class="modal-subtitle">{mangaTitle}</span>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="state-body">
|
||||
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
<span class="state-label">Loading…</span>
|
||||
</div>
|
||||
|
||||
{:else if loggedInTrackers.length === 0}
|
||||
<div class="state-body">
|
||||
<p class="state-text">No trackers connected.</p>
|
||||
<p class="state-hint">Go to Settings → Tracking to log in.</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:tab-active={activeTab === "records"}
|
||||
onclick={() => activeTab = "records"}
|
||||
>
|
||||
My List
|
||||
{#if records.length > 0}
|
||||
<span class="tab-badge">{records.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#each loggedInTrackers as t}
|
||||
{@const rec = recordFor(t.id)}
|
||||
<button
|
||||
class="tab"
|
||||
class:tab-active={activeTab === t.id}
|
||||
onclick={() => { activeTab = t.id; searchResults = []; }}
|
||||
>
|
||||
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-icon" />
|
||||
{t.name}
|
||||
{#if rec}<span class="tab-dot"></span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if activeTab === "records"}
|
||||
<div class="tab-body">
|
||||
{#if records.length === 0}
|
||||
<div class="state-body">
|
||||
<p class="state-text">Not tracking this manga yet.</p>
|
||||
<p class="state-hint">Click a tracker tab above to search and add it.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each records as record (record.id)}
|
||||
{@const tracker = trackerFor(record.trackerId)}
|
||||
{@const isBusy = updatingRecord === record.id}
|
||||
<div class="record-row" class:record-busy={isBusy}>
|
||||
|
||||
<div class="record-identity">
|
||||
{#if tracker}
|
||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-icon" />
|
||||
{/if}
|
||||
{#if record.remoteUrl}
|
||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
|
||||
{record.title}
|
||||
<ArrowSquareOut size={10} weight="light" />
|
||||
</a>
|
||||
{:else}
|
||||
<span class="record-title-plain">{record.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="record-controls">
|
||||
<select
|
||||
class="record-select"
|
||||
value={record.status}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
||||
>
|
||||
{#each (tracker?.statuses ?? []) as s}
|
||||
<option value={s.value}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="record-select record-select-score"
|
||||
value={record.displayScore}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{#each (tracker?.scores ?? []) as s}
|
||||
<option value={s}>★ {s}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if tracker?.supportsPrivateTracking}
|
||||
<button
|
||||
class="record-icon-btn"
|
||||
class:icon-active={record.private}
|
||||
title={record.private ? "Private — click to make public" : "Public — click to make private"}
|
||||
disabled={isBusy}
|
||||
onclick={() => togglePrivate(record)}
|
||||
>
|
||||
{#if record.private}
|
||||
<Lock size={12} weight="fill" />
|
||||
{:else}
|
||||
<LockOpen size={12} weight="light" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="record-icon-btn"
|
||||
title="Sync from tracker"
|
||||
disabled={syncing === record.id}
|
||||
onclick={() => syncRecord(record)}
|
||||
>
|
||||
<ArrowsClockwise size={12} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="record-icon-btn icon-danger"
|
||||
title="Unlink"
|
||||
disabled={isBusy}
|
||||
onclick={() => unbind(record)}
|
||||
>
|
||||
<X size={12} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if editingChapter === record.id}
|
||||
<div class="chapter-editor">
|
||||
<div class="chapter-editor-top">
|
||||
<span class="chapter-editor-label">Chapter read</span>
|
||||
<div class="chapter-input-wrap">
|
||||
<input
|
||||
type="number"
|
||||
class="chapter-input"
|
||||
min="0"
|
||||
max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||
step="0.5"
|
||||
bind:value={chapterDraft}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") submitChapter(record);
|
||||
if (e.key === "Escape") cancelChapterEditor();
|
||||
}}
|
||||
use:autoFocus
|
||||
/>
|
||||
{#if record.totalChapters > 0}
|
||||
<span class="chapter-total">/ {record.totalChapters}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if record.totalChapters > 0}
|
||||
<input
|
||||
type="range"
|
||||
class="chapter-slider"
|
||||
min="0"
|
||||
max={record.totalChapters}
|
||||
step="1"
|
||||
bind:value={chapterDraft}
|
||||
/>
|
||||
{/if}
|
||||
<div class="chapter-editor-actions">
|
||||
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if record.totalChapters > 0}
|
||||
<div class="record-progress clickable" role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to edit"
|
||||
>
|
||||
<span class="record-progress-label">Ch. {record.lastChapterRead} / {record.totalChapters} <span class="edit-hint">✎</span></span>
|
||||
<div class="record-progress-track">
|
||||
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="record-progress clickable" role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to set chapter"
|
||||
>
|
||||
<span class="record-progress-label">
|
||||
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="edit-hint">✎</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{@const tracker = trackerFor(activeTab as number)}
|
||||
{@const boundRecord = recordFor(activeTab as number)}
|
||||
<div class="search-bar">
|
||||
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="Search {tracker?.name}…"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
onkeydown={(e) => e.key === "Enter" && doSearch(activeTab as number, searchQuery)}
|
||||
use:autoFocus
|
||||
/>
|
||||
{#if searching}
|
||||
<CircleNotch size={13} weight="light" class="anim-spin search-icon" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="search-results">
|
||||
{#if searching && searchResults.length === 0}
|
||||
<div class="state-body"><p class="state-hint">Searching…</p></div>
|
||||
{:else if !searching && searchQuery.trim() && searchResults.length === 0}
|
||||
<div class="state-body"><p class="state-text">No results for "{searchQuery}"</p></div>
|
||||
{:else if !searchQuery.trim()}
|
||||
<div class="state-body"><p class="state-hint">Type a title to search</p></div>
|
||||
{:else}
|
||||
{#each searchResults as result (result.trackerId + ":" + result.remoteId)}
|
||||
{@const isBound = boundRecord?.remoteId === result.remoteId}
|
||||
<button
|
||||
class="result-row"
|
||||
class:result-bound={isBound}
|
||||
onclick={() => isBound ? unbind(boundRecord!) : bind(result)}
|
||||
disabled={binding}
|
||||
>
|
||||
{#if result.coverUrl}
|
||||
<img
|
||||
src={result.coverUrl}
|
||||
alt={result.title}
|
||||
class="result-cover"
|
||||
loading="lazy"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
{:else}
|
||||
<div class="result-cover result-cover-empty"></div>
|
||||
{/if}
|
||||
<div class="result-info">
|
||||
<span class="result-title">{result.title}</span>
|
||||
<div class="result-meta">
|
||||
{#if result.publishingType}<span class="result-tag">{result.publishingType}</span>{/if}
|
||||
{#if result.publishingStatus}<span class="result-tag">{result.publishingStatus}</span>{/if}
|
||||
{#if result.totalChapters > 0}<span class="result-tag">{result.totalChapters} ch</span>{/if}
|
||||
</div>
|
||||
{#if result.summary}
|
||||
<p class="result-summary">{result.summary.slice(0,140)}{result.summary.length > 140 ? "…" : ""}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="result-action" class:result-action-on={isBound}>
|
||||
{isBound ? "✓ Tracking" : "Track"}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script module>
|
||||
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.72);
|
||||
z-index: var(--z-settings);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.12s ease both;
|
||||
}
|
||||
.modal {
|
||||
width: min(560px, calc(100vw - 48px));
|
||||
max-height: min(660px, calc(100vh - 80px));
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl); overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.15s ease both;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
/* States */
|
||||
.state-body { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-10) var(--sp-5); flex: 1; }
|
||||
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab {
|
||||
display: flex; align-items: center; gap: var(--sp-2); position: relative;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 10px 10px 9px; color: var(--text-faint);
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
|
||||
.tab-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
|
||||
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
|
||||
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||
|
||||
/* Records */
|
||||
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-4); scrollbar-width: none; display: flex; flex-direction: column; gap: 2px; }
|
||||
.tab-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
.record-row {
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.record-row:hover { background: var(--bg-overlay); }
|
||||
.record-busy { opacity: 0.45; pointer-events: none; }
|
||||
|
||||
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
||||
.record-tracker-icon { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.8; }
|
||||
.record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); }
|
||||
.record-title:hover { opacity: 0.75; }
|
||||
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
|
||||
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.record-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 22px 3px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent; background: var(--bg-overlay);
|
||||
color: var(--text-faint); outline: none; cursor: pointer;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 6px center;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.record-select:focus { border-color: var(--border-strong); outline: none; color: var(--text-secondary); }
|
||||
.record-select:disabled { opacity: 0.35; cursor: default; }
|
||||
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
.record-select-score { max-width: 90px; }
|
||||
|
||||
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.record-icon-btn.icon-active { color: var(--accent-fg); }
|
||||
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.record-progress { display: flex; flex-direction: column; gap: 4px; }
|
||||
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); }
|
||||
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
||||
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); display: flex; align-items: center; gap: var(--sp-1); }
|
||||
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); color: var(--text-faint); }
|
||||
.record-progress.clickable:hover .edit-hint { opacity: 1; }
|
||||
.record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
|
||||
/* Chapter editor */
|
||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); }
|
||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.chapter-input { width: 64px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
|
||||
.chapter-input:focus { border-color: var(--accent); }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
|
||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||
.chapter-save-btn:hover { filter: brightness(1.15); }
|
||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
|
||||
.chapter-cancel-btn:hover { color: var(--text-muted); }
|
||||
|
||||
/* Search */
|
||||
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; background: var(--bg-surface); }
|
||||
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
|
||||
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.search-input::placeholder { color: var(--text-faint); }
|
||||
.search-results { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
||||
.search-results::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Results */
|
||||
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
||||
.result-row:disabled { opacity: 0.4; cursor: default; }
|
||||
.result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
|
||||
.result-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.result-cover-empty { background: var(--bg-raised); }
|
||||
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
|
||||
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
|
||||
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
||||
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
|
||||
.result-action { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
import { store, updateSettings } from "../store/state.svelte";
|
||||
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
|
||||
export const authSession = {
|
||||
clearTokens() {},
|
||||
hasSession(): boolean { return true; },
|
||||
};
|
||||
|
||||
function getServerBase(): string {
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
function buildRequestInit(init: RequestInit, extraHeaders: Record<string, string> = {}): RequestInit {
|
||||
return {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { ...(init.headers as Record<string, string> ?? {}), ...extraHeaders },
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
const headers = user && pass ? basicHeader(user, pass) : {};
|
||||
return fetch(url, buildRequestInit({ ...init, signal }, headers));
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, signal });
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
updateSettings({ serverAuthPass: "" });
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (mode === "SIMPLE_LOGIN" || mode === "UI_LOGIN") {
|
||||
updateSettings({ serverAuthMode: "NONE" });
|
||||
}
|
||||
return "ok";
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
||||
|
||||
if (/basic/i.test(wwwAuth)) {
|
||||
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
||||
return "auth_required";
|
||||
}
|
||||
|
||||
if (/bearer/i.test(wwwAuth)) {
|
||||
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
} else if (mode === "NONE") {
|
||||
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
||||
}
|
||||
return "unsupported_mode";
|
||||
}
|
||||
|
||||
return "unreachable";
|
||||
} catch { return "unreachable"; }
|
||||
}
|
||||
@@ -154,6 +154,7 @@ export const CACHE_GROUPS = {
|
||||
export const CACHE_KEYS = {
|
||||
LIBRARY: "library",
|
||||
ALL_MANGA: "all_manga_unfiltered",
|
||||
CATEGORIES: "categories",
|
||||
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
|
||||
SOURCES: "sources",
|
||||
POPULAR: "popular",
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { store } from "../store/state.svelte";
|
||||
import { fetchAuthenticated } from "./auth";
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||
|
||||
function getServerUrl(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem("moku-store");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const url = parsed?.state?.settings?.serverUrl;
|
||||
if (typeof url === "string" && url.trim()) return url.replace(/\/$/, "");
|
||||
}
|
||||
} catch {}
|
||||
return DEFAULT_URL;
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||
}
|
||||
|
||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||
@@ -17,7 +13,22 @@ function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||
export function thumbUrl(path: string): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${getServerUrl()}${path}`;
|
||||
|
||||
const base = getServerUrl();
|
||||
const mode = store.settings.serverAuthMode;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) {
|
||||
const url = new URL(`${base}${path}`);
|
||||
url.username = user;
|
||||
url.password = pass;
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
interface GQLResponse<T> {
|
||||
@@ -25,7 +36,6 @@ interface GQLResponse<T> {
|
||||
errors?: { message: string }[];
|
||||
}
|
||||
|
||||
/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||
@@ -37,12 +47,6 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry wrapper with these guarantees:
|
||||
* 1. AbortErrors always propagate immediately — no retry, no delay.
|
||||
* 2. Retry delays are abort-aware — closing a manga mid-delay doesn't hang.
|
||||
* 3. If the signal is already aborted before we even start, we bail instantly.
|
||||
*/
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
@@ -50,29 +54,19 @@ async function fetchWithRetry(
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
): Promise<Response> {
|
||||
// Bail immediately if already aborted before we start
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
// Check abort at the top of every iteration
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal });
|
||||
|
||||
// Check abort again — fetch can return a response even after abort in some runtimes
|
||||
const res = await fetchAuthenticated(url, init, signal);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
// Never retry aborted requests
|
||||
if (e?.authRequired) throw e;
|
||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
// Last retry — give up
|
||||
if (i === retries - 1) throw e;
|
||||
|
||||
// Abort-aware delay between retries
|
||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +84,6 @@ export async function gql<T>(
|
||||
body: JSON.stringify({ query, variables }),
|
||||
}, signal);
|
||||
|
||||
// Check abort before reading the body — avoids hanging on res.json() after cancel
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { start, stop, setActivity, clearActivity } from "tauri-plugin-drpc";
|
||||
import { Activity, Assets, Button, Timestamps } from "tauri-plugin-drpc/activity";
|
||||
import type { Manga, Chapter } from "./types";
|
||||
|
||||
const APP_ID = "1487894643613106298";
|
||||
const FALLBACK_IMAGE = "moku_logo";
|
||||
|
||||
let sessionStart: number | null = null;
|
||||
|
||||
function isPublicUrl(url: string | null | undefined): boolean {
|
||||
return typeof url === "string" && url.startsWith("https://");
|
||||
}
|
||||
|
||||
function resolveCoverImage(manga: Manga): string {
|
||||
return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE;
|
||||
}
|
||||
|
||||
function trunc(s: string, max = 128): string {
|
||||
return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
function formatChapter(chapter: Chapter): string {
|
||||
const n = chapter.chapterNumber;
|
||||
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`;
|
||||
}
|
||||
|
||||
function getTimestamps(): Timestamps {
|
||||
return new Timestamps(sessionStart ?? Date.now());
|
||||
}
|
||||
|
||||
const BUTTONS = [
|
||||
new Button("GitHub", "https://github.com/Youwes09/Moku"),
|
||||
new Button("Discord", "https://discord.gg/Jq3pwuNqPp"),
|
||||
];
|
||||
|
||||
export async function initRpc(): Promise<void> {
|
||||
sessionStart = Date.now();
|
||||
await start(APP_ID).catch(() => {});
|
||||
}
|
||||
|
||||
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
|
||||
const assets = new Assets()
|
||||
.setLargeImage(resolveCoverImage(manga))
|
||||
.setLargeText(trunc(manga.title))
|
||||
.setSmallImage(FALLBACK_IMAGE)
|
||||
.setSmallText("Moku");
|
||||
|
||||
const activity = new Activity()
|
||||
.setDetails(trunc(manga.title))
|
||||
.setState(`${formatChapter(chapter)} · Reading`)
|
||||
.setAssets(assets)
|
||||
.setTimestamps(getTimestamps());
|
||||
activity.setButton(BUTTONS);
|
||||
|
||||
await setActivity(activity).catch(() => {});
|
||||
}
|
||||
|
||||
export async function setIdle(): Promise<void> {
|
||||
const assets = new Assets()
|
||||
.setLargeImage(FALLBACK_IMAGE)
|
||||
.setLargeText("Moku");
|
||||
|
||||
const activity = new Activity()
|
||||
.setDetails("Browsing")
|
||||
.setAssets(assets)
|
||||
.setTimestamps(getTimestamps());
|
||||
activity.setButton(BUTTONS);
|
||||
|
||||
await setActivity(activity).catch(() => {});
|
||||
}
|
||||
|
||||
export async function clearReading(): Promise<void> {
|
||||
await clearActivity().catch(() => {});
|
||||
}
|
||||
|
||||
export async function destroyRpc(): Promise<void> {
|
||||
sessionStart = null;
|
||||
await stop().catch(() => {});
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface Keybinds {
|
||||
togglePageStyle: string;
|
||||
toggleFullscreen: string;
|
||||
openSettings: string;
|
||||
toggleBookmark: string;
|
||||
toggleMarker: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
@@ -26,6 +28,8 @@ export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
togglePageStyle: "q",
|
||||
toggleFullscreen: "f",
|
||||
openSettings: "o",
|
||||
toggleBookmark: "m",
|
||||
toggleMarker: "n",
|
||||
};
|
||||
|
||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
@@ -40,6 +44,8 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
togglePageStyle: "Toggle page style",
|
||||
toggleFullscreen: "Toggle fullscreen",
|
||||
openSettings: "Open settings",
|
||||
toggleBookmark: "Toggle bookmark",
|
||||
toggleMarker: "Toggle marker",
|
||||
};
|
||||
|
||||
export function eventToKeybind(e: KeyboardEvent): string {
|
||||
|
||||
@@ -187,6 +187,112 @@ export const GET_DOWNLOADS_PATH = `
|
||||
query GetDownloadsPath {
|
||||
settings {
|
||||
downloadsPath
|
||||
localSourcePath
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_DOWNLOADS_PATH = `
|
||||
mutation SetDownloadsPath($path: String!) {
|
||||
setSettings(input: { settings: { downloadsPath: $path } }) {
|
||||
settings { downloadsPath }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_LOCAL_SOURCE_PATH = `
|
||||
mutation SetLocalSourcePath($path: String!) {
|
||||
setSettings(input: { settings: { localSourcePath: $path } }) {
|
||||
settings { localSourcePath }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Categories ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GET_CATEGORIES = `
|
||||
query GetCategories {
|
||||
categories {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
order
|
||||
default
|
||||
includeInUpdate
|
||||
includeInDownload
|
||||
mangas {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
inLibrary
|
||||
downloadCount
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CREATE_CATEGORY = `
|
||||
mutation CreateCategory($name: String!) {
|
||||
createCategory(input: { name: $name }) {
|
||||
category {
|
||||
id
|
||||
name
|
||||
order
|
||||
default
|
||||
includeInUpdate
|
||||
includeInDownload
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_CATEGORY = `
|
||||
mutation UpdateCategory($id: Int!, $name: String) {
|
||||
updateCategory(input: { id: $id, patch: { name: $name } }) {
|
||||
category {
|
||||
id
|
||||
name
|
||||
order
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DELETE_CATEGORY = `
|
||||
mutation DeleteCategory($id: Int!) {
|
||||
deleteCategory(input: { categoryId: $id }) {
|
||||
category {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_CATEGORY_ORDER = `
|
||||
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
|
||||
updateCategoryOrder(input: { id: $id, position: $position }) {
|
||||
categories {
|
||||
id
|
||||
name
|
||||
order
|
||||
default
|
||||
includeInUpdate
|
||||
includeInDownload
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_MANGA_CATEGORIES = `
|
||||
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||
manga {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -335,8 +441,8 @@ export const GET_SOURCES = `
|
||||
`;
|
||||
|
||||
export const FETCH_SOURCE_MANGA = `
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||
mangas {
|
||||
id
|
||||
title
|
||||
@@ -436,6 +542,7 @@ export const INSTALL_EXTERNAL_EXTENSION = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GET_SETTINGS = `
|
||||
@@ -455,3 +562,346 @@ export const SET_EXTENSION_REPOS = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_SERVER_SECURITY = `
|
||||
query GetServerSecurity {
|
||||
settings {
|
||||
authMode
|
||||
authUsername
|
||||
socksProxyEnabled
|
||||
socksProxyHost
|
||||
socksProxyPort
|
||||
socksProxyVersion
|
||||
socksProxyUsername
|
||||
flareSolverrEnabled
|
||||
flareSolverrUrl
|
||||
flareSolverrTimeout
|
||||
flareSolverrSessionName
|
||||
flareSolverrSessionTtl
|
||||
flareSolverrAsResponseFallback
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_SERVER_AUTH = `
|
||||
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
|
||||
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
|
||||
settings {
|
||||
authMode
|
||||
authUsername
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_SOCKS_PROXY = `
|
||||
mutation SetSocksProxy(
|
||||
$socksProxyEnabled: Boolean!
|
||||
$socksProxyHost: String!
|
||||
$socksProxyPort: String!
|
||||
$socksProxyVersion: Int!
|
||||
$socksProxyUsername: String!
|
||||
$socksProxyPassword: String!
|
||||
) {
|
||||
setSettings(input: { settings: {
|
||||
socksProxyEnabled: $socksProxyEnabled
|
||||
socksProxyHost: $socksProxyHost
|
||||
socksProxyPort: $socksProxyPort
|
||||
socksProxyVersion: $socksProxyVersion
|
||||
socksProxyUsername: $socksProxyUsername
|
||||
socksProxyPassword: $socksProxyPassword
|
||||
}}) {
|
||||
settings {
|
||||
socksProxyEnabled
|
||||
socksProxyHost
|
||||
socksProxyPort
|
||||
socksProxyVersion
|
||||
socksProxyUsername
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_FLARESOLVERR = `
|
||||
mutation SetFlareSolverr(
|
||||
$flareSolverrEnabled: Boolean!
|
||||
$flareSolverrUrl: String!
|
||||
$flareSolverrTimeout: Int!
|
||||
$flareSolverrSessionName: String!
|
||||
$flareSolverrSessionTtl: Int!
|
||||
$flareSolverrAsResponseFallback: Boolean!
|
||||
) {
|
||||
setSettings(input: { settings: {
|
||||
flareSolverrEnabled: $flareSolverrEnabled
|
||||
flareSolverrUrl: $flareSolverrUrl
|
||||
flareSolverrTimeout: $flareSolverrTimeout
|
||||
flareSolverrSessionName: $flareSolverrSessionName
|
||||
flareSolverrSessionTtl: $flareSolverrSessionTtl
|
||||
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
|
||||
}}) {
|
||||
settings {
|
||||
flareSolverrEnabled
|
||||
flareSolverrUrl
|
||||
flareSolverrTimeout
|
||||
flareSolverrSessionName
|
||||
flareSolverrSessionTtl
|
||||
flareSolverrAsResponseFallback
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Trackers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GET_TRACKERS = `
|
||||
query GetTrackers {
|
||||
trackers {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
icon
|
||||
isLoggedIn
|
||||
authUrl
|
||||
supportsPrivateTracking
|
||||
scores
|
||||
statuses {
|
||||
value
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_MANGA_TRACK_RECORDS = `
|
||||
query GetMangaTrackRecords($mangaId: Int!) {
|
||||
manga(id: $mangaId) {
|
||||
trackRecords {
|
||||
nodes {
|
||||
id
|
||||
trackerId
|
||||
remoteId
|
||||
title
|
||||
status
|
||||
score
|
||||
displayScore
|
||||
lastChapterRead
|
||||
totalChapters
|
||||
remoteUrl
|
||||
startDate
|
||||
finishDate
|
||||
private
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SEARCH_TRACKER = `
|
||||
query SearchTracker($trackerId: Int!, $query: String!) {
|
||||
searchTracker(input: { trackerId: $trackerId, query: $query }) {
|
||||
trackSearches {
|
||||
id
|
||||
trackerId
|
||||
remoteId
|
||||
title
|
||||
coverUrl
|
||||
summary
|
||||
publishingStatus
|
||||
publishingType
|
||||
startDate
|
||||
totalChapters
|
||||
trackingUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const BIND_TRACK = `
|
||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||
trackRecord {
|
||||
id
|
||||
trackerId
|
||||
remoteId
|
||||
title
|
||||
status
|
||||
score
|
||||
displayScore
|
||||
lastChapterRead
|
||||
totalChapters
|
||||
remoteUrl
|
||||
startDate
|
||||
finishDate
|
||||
private
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_TRACK = `
|
||||
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
||||
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
|
||||
trackRecord {
|
||||
id
|
||||
trackerId
|
||||
status
|
||||
score
|
||||
displayScore
|
||||
lastChapterRead
|
||||
totalChapters
|
||||
startDate
|
||||
finishDate
|
||||
private
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UNBIND_TRACK = `
|
||||
mutation UnbindTrack($recordId: Int!) {
|
||||
unbindTrack(input: { recordId: $recordId }) {
|
||||
trackRecord {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FETCH_TRACK = `
|
||||
mutation FetchTrack($recordId: Int!) {
|
||||
fetchTrack(input: { recordId: $recordId }) {
|
||||
trackRecord {
|
||||
id
|
||||
trackerId
|
||||
status
|
||||
score
|
||||
displayScore
|
||||
lastChapterRead
|
||||
totalChapters
|
||||
startDate
|
||||
finishDate
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_ALL_TRACKER_RECORDS = `
|
||||
query GetAllTrackerRecords {
|
||||
trackers {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
icon
|
||||
isLoggedIn
|
||||
scores
|
||||
statuses { value name }
|
||||
trackRecords {
|
||||
nodes {
|
||||
id
|
||||
trackerId
|
||||
title
|
||||
status
|
||||
displayScore
|
||||
lastChapterRead
|
||||
totalChapters
|
||||
remoteUrl
|
||||
private
|
||||
manga {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
inLibrary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_TRACKER_RECORDS = `
|
||||
query GetTrackerRecords($trackerId: Int!) {
|
||||
trackers(condition: { id: $trackerId }) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
statuses { value name }
|
||||
trackRecords {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
status
|
||||
displayScore
|
||||
lastChapterRead
|
||||
totalChapters
|
||||
remoteUrl
|
||||
manga {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_TRACKER_OAUTH = `
|
||||
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||
isLoggedIn
|
||||
tracker {
|
||||
id
|
||||
name
|
||||
isLoggedIn
|
||||
authUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_TRACKER_CREDENTIALS = `
|
||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
||||
isLoggedIn
|
||||
tracker {
|
||||
id
|
||||
name
|
||||
isLoggedIn
|
||||
authUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGOUT_TRACKER = `
|
||||
mutation LogoutTracker($trackerId: Int!) {
|
||||
logoutTracker(input: { trackerId: $trackerId }) {
|
||||
tracker {
|
||||
id
|
||||
name
|
||||
isLoggedIn
|
||||
authUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken
|
||||
refreshToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REFRESH_TOKEN = `
|
||||
mutation RefreshToken {
|
||||
refreshToken {
|
||||
accessToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
order: number;
|
||||
default: boolean;
|
||||
includeInUpdate: string;
|
||||
includeInDownload: string;
|
||||
mangas?: {
|
||||
nodes: Manga[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Manga {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -5,6 +17,7 @@ export interface Manga {
|
||||
inLibrary: boolean;
|
||||
downloadCount?: number;
|
||||
unreadCount?: number;
|
||||
chapterCount?: number;
|
||||
description?: string | null;
|
||||
status?: string | null;
|
||||
author?: string | null;
|
||||
@@ -87,3 +100,49 @@ export interface DownloadStatus {
|
||||
export interface Connection<T> {
|
||||
nodes: T[];
|
||||
}
|
||||
|
||||
export interface TrackerStatus {
|
||||
value: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Tracker {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
isLoggedIn: boolean;
|
||||
authUrl: string | null;
|
||||
supportsPrivateTracking: boolean;
|
||||
scores: string[];
|
||||
statuses: TrackerStatus[];
|
||||
}
|
||||
|
||||
export interface TrackRecord {
|
||||
id: number;
|
||||
trackerId: number;
|
||||
remoteId: string;
|
||||
title: string;
|
||||
status: number;
|
||||
score: number;
|
||||
displayScore: string;
|
||||
lastChapterRead: number;
|
||||
totalChapters: number;
|
||||
remoteUrl: string | null;
|
||||
startDate: string | null;
|
||||
finishDate: string | null;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export interface TrackSearch {
|
||||
id: number;
|
||||
trackerId: number;
|
||||
remoteId: string;
|
||||
title: string;
|
||||
coverUrl: string | null;
|
||||
summary: string | null;
|
||||
publishingStatus: string | null;
|
||||
publishingType: string | null;
|
||||
startDate: string | null;
|
||||
totalChapters: number;
|
||||
trackingUrl: string | null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,105 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
// ── NSFW genre filtering ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default substrings used when no user-configured list is available.
|
||||
* The Settings > Content tab lets users add/remove entries from this list,
|
||||
* which is stored as settings.nsfwFilteredTags.
|
||||
*/
|
||||
export const DEFAULT_NSFW_TAGS = [
|
||||
"adult",
|
||||
"mature",
|
||||
"hentai",
|
||||
"ecchi",
|
||||
"erotic", // catches "erotica", "erotic content", "erotic manga"
|
||||
"pornograph", // catches "pornographic", "pornography"
|
||||
"18+",
|
||||
"smut",
|
||||
"lemon",
|
||||
"explicit",
|
||||
"sexual violence",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns true if the manga carries at least one genre tag matching any of
|
||||
* the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags
|
||||
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
|
||||
*/
|
||||
export function isNsfwManga(
|
||||
manga: { genre?: string[] | null },
|
||||
tags: string[] = DEFAULT_NSFW_TAGS,
|
||||
): boolean {
|
||||
return (manga.genre ?? []).some((g) => {
|
||||
const normalized = g.toLowerCase().trim();
|
||||
return tags.some((sub) => normalized.includes(sub));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single authoritative NSFW gate used by all views.
|
||||
*
|
||||
* Returns true when the manga should be HIDDEN. Checks in order:
|
||||
* 1. showNsfw disabled globally → skip everything, hide by source flag or genre match.
|
||||
* 2. Source is in blockedSourceIds → always hide regardless of showNsfw.
|
||||
* 3. Source is in allowedSourceIds → always show (bypasses isNsfw flag only, genre tags still apply).
|
||||
* 4. Source isNsfw flag → hide unless source is allowed.
|
||||
* 5. Genre tag match → hide.
|
||||
*
|
||||
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
|
||||
*/
|
||||
export function shouldHideNsfw(
|
||||
manga: {
|
||||
genre?: string[] | null;
|
||||
source?: { id?: string; isNsfw?: boolean } | null;
|
||||
},
|
||||
settings: {
|
||||
showNsfw: boolean;
|
||||
nsfwFilteredTags: string[];
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
},
|
||||
): boolean {
|
||||
const srcId = manga.source?.id;
|
||||
|
||||
// Explicit block always wins, even when showNsfw is on
|
||||
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
|
||||
|
||||
// If NSFW is globally allowed, only explicit blocks apply
|
||||
if (settings.showNsfw) return false;
|
||||
|
||||
// Source is explicitly allowed — skip the isNsfw flag check, but still filter genres
|
||||
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
|
||||
|
||||
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||
|
||||
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for Source objects — parallel to shouldHideNsfw for manga.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Blocked list → always hidden, even when showNsfw is on.
|
||||
* 2. Allowed list → always shown, even if isNsfw is true.
|
||||
* 3. Fallback → hide when showNsfw is off and source.isNsfw is true.
|
||||
*
|
||||
* Usage: sources.filter(s => !shouldHideSource(s, settings))
|
||||
*/
|
||||
export function shouldHideSource(
|
||||
source: { id: string; isNsfw: boolean },
|
||||
settings: {
|
||||
showNsfw: boolean;
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
},
|
||||
): boolean {
|
||||
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
|
||||
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
|
||||
return !settings.showNsfw && source.isNsfw;
|
||||
}
|
||||
|
||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||
|
||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||
|
||||
@@ -1,15 +1,104 @@
|
||||
import type { Manga, Chapter, Source } from "../lib/types";
|
||||
import type { Manga, Chapter, Category, Source } from "../lib/types";
|
||||
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;
|
||||
export type NavPage = "home" | "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
||||
export type NavPage = "home" | "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search" | "tracking";
|
||||
export type ReadingDirection = "ltr" | "rtl";
|
||||
export type ChapterSortDir = "desc" | "asc";
|
||||
export type Theme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
||||
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
|
||||
|
||||
export const COMPLETED_FOLDER_ID = "completed";
|
||||
export type LibrarySortMode =
|
||||
| "az"
|
||||
| "unreadCount"
|
||||
| "totalChapters"
|
||||
| "recentlyAdded"
|
||||
| "recentlyRead"
|
||||
| "latestFetched"
|
||||
| "latestUploaded";
|
||||
|
||||
export type LibrarySortDir = "asc" | "desc";
|
||||
|
||||
export type LibraryStatusFilter =
|
||||
| "ALL"
|
||||
| "ONGOING"
|
||||
| "COMPLETED"
|
||||
| "CANCELLED"
|
||||
| "HIATUS"
|
||||
| "UNKNOWN";
|
||||
|
||||
export type LibraryContentFilter =
|
||||
| "unread"
|
||||
| "started"
|
||||
| "downloaded"
|
||||
| "bookmarked"
|
||||
| "marked";
|
||||
|
||||
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
||||
export type Theme = BuiltinTheme | string;
|
||||
|
||||
export interface ThemeTokens {
|
||||
"bg-void": string;
|
||||
"bg-base": string;
|
||||
"bg-surface": string;
|
||||
"bg-raised": string;
|
||||
"bg-overlay": string;
|
||||
"bg-subtle": string;
|
||||
"border-dim": string;
|
||||
"border-base": string;
|
||||
"border-strong": string;
|
||||
"border-focus": string;
|
||||
"text-primary": string;
|
||||
"text-secondary": string;
|
||||
"text-muted": string;
|
||||
"text-faint": string;
|
||||
"text-disabled": string;
|
||||
"accent": string;
|
||||
"accent-dim": string;
|
||||
"accent-muted": string;
|
||||
"accent-fg": string;
|
||||
"accent-bright": string;
|
||||
"color-error": string;
|
||||
"color-error-bg": string;
|
||||
"color-success": string;
|
||||
"color-info": string;
|
||||
"color-info-bg": string;
|
||||
}
|
||||
|
||||
export interface CustomTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
tokens: ThemeTokens;
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_TOKENS: ThemeTokens = {
|
||||
"bg-void": "#080808",
|
||||
"bg-base": "#0c0c0c",
|
||||
"bg-surface": "#101010",
|
||||
"bg-raised": "#151515",
|
||||
"bg-overlay": "#1a1a1a",
|
||||
"bg-subtle": "#202020",
|
||||
"border-dim": "#1c1c1c",
|
||||
"border-base": "#242424",
|
||||
"border-strong": "#2e2e2e",
|
||||
"border-focus": "#4a5c4a",
|
||||
"text-primary": "#f0efec",
|
||||
"text-secondary": "#c8c6c0",
|
||||
"text-muted": "#8a8880",
|
||||
"text-faint": "#4e4d4a",
|
||||
"text-disabled": "#2a2a28",
|
||||
"accent": "#6b8f6b",
|
||||
"accent-dim": "#2a3d2a",
|
||||
"accent-muted": "#1a251a",
|
||||
"accent-fg": "#a8c4a8",
|
||||
"accent-bright": "#8fb88f",
|
||||
"color-error": "#c47a7a",
|
||||
"color-error-bg": "#1f1212",
|
||||
"color-success": "#7aab7a",
|
||||
"color-info": "#7a9ec4",
|
||||
"color-info-bg": "#121a1f",
|
||||
};
|
||||
|
||||
export interface HistoryEntry {
|
||||
mangaId: number;
|
||||
@@ -17,10 +106,43 @@ export interface HistoryEntry {
|
||||
thumbnailUrl: string;
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
pageNumber: number;
|
||||
readAt: number;
|
||||
}
|
||||
|
||||
export interface BookmarkEntry {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
pageNumber: number;
|
||||
savedAt: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple";
|
||||
|
||||
export interface MarkerEntry {
|
||||
id: string;
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
pageNumber: number;
|
||||
note: string;
|
||||
color: MarkerColor;
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface ReadLogEntry {
|
||||
mangaId: number;
|
||||
chapterId: number;
|
||||
readAt: number;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export interface ReadingStats {
|
||||
totalChaptersRead: number;
|
||||
totalMangaRead: number;
|
||||
@@ -59,19 +181,33 @@ export interface ActiveDownload {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
mangaIds: number[];
|
||||
showTab: boolean;
|
||||
system?: boolean;
|
||||
export interface MangaPrefs {
|
||||
autoDownload: boolean;
|
||||
downloadAhead: number;
|
||||
deleteOnRead: boolean;
|
||||
deleteDelayHours: number;
|
||||
maxKeepChapters: number;
|
||||
pauseUpdates: boolean;
|
||||
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
||||
preferredScanlator: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||
autoDownload: false,
|
||||
downloadAhead: 0,
|
||||
deleteOnRead: false,
|
||||
deleteDelayHours: 0,
|
||||
maxKeepChapters: 0,
|
||||
pauseUpdates: false,
|
||||
refreshInterval: "global",
|
||||
preferredScanlator: "",
|
||||
};
|
||||
|
||||
export interface Settings {
|
||||
pageStyle: PageStyle;
|
||||
readingDirection: ReadingDirection;
|
||||
fitMode: FitMode;
|
||||
maxPageWidth: number;
|
||||
readerZoom: number;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
offsetDoubleSpreads: boolean;
|
||||
@@ -81,9 +217,11 @@ export interface Settings {
|
||||
libraryCropCovers: boolean;
|
||||
libraryPageSize: number;
|
||||
showNsfw: boolean;
|
||||
discordRpc: boolean;
|
||||
chapterSortDir: ChapterSortDir;
|
||||
chapterSortMode: ChapterSortMode;
|
||||
chapterPageSize: number;
|
||||
uiScale: number;
|
||||
uiZoom: number;
|
||||
compactSidebar: boolean;
|
||||
gpuAcceleration: boolean;
|
||||
serverUrl: string;
|
||||
@@ -94,29 +232,53 @@ export interface Settings {
|
||||
idleTimeoutMin?: number;
|
||||
splashCards?: boolean;
|
||||
storageLimitGb: number | null;
|
||||
folders: Folder[];
|
||||
markReadOnNext: boolean;
|
||||
readerDebounceMs: number;
|
||||
autoBookmark: boolean;
|
||||
theme: Theme;
|
||||
libraryBranches: boolean;
|
||||
renderLimit: number;
|
||||
heroSlots: (number | null)[];
|
||||
mangaLinks: Record<number, number[]>;
|
||||
mangaPrefs: Record<number, Partial<MangaPrefs>>;
|
||||
serverAuthUser: string;
|
||||
serverAuthPass: string;
|
||||
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
socksProxyEnabled: boolean;
|
||||
socksProxyHost: string;
|
||||
socksProxyPort: string;
|
||||
socksProxyVersion: number;
|
||||
socksProxyUsername: string;
|
||||
socksProxyPassword: string;
|
||||
flareSolverrEnabled: boolean;
|
||||
flareSolverrUrl: string;
|
||||
flareSolverrTimeout: number;
|
||||
flareSolverrSessionName: string;
|
||||
flareSolverrSessionTtl: number;
|
||||
flareSolverrFallback: boolean;
|
||||
appLockEnabled: boolean;
|
||||
appLockPin: string;
|
||||
customThemes: CustomTheme[];
|
||||
hiddenCategoryIds: number[];
|
||||
defaultLibraryCategoryId: number | null;
|
||||
nsfwFilteredTags: string[];
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
||||
maxPageWidth?: number;
|
||||
uiScale?: number;
|
||||
extraScanDirs: string[];
|
||||
serverDownloadsPath: string;
|
||||
serverLocalSourcePath: string;
|
||||
}
|
||||
|
||||
const COMPLETED_FOLDER_DEFAULT: Folder = {
|
||||
id: COMPLETED_FOLDER_ID,
|
||||
name: "Completed",
|
||||
mangaIds: [],
|
||||
showTab: true,
|
||||
system: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
pageStyle: "longstrip",
|
||||
readingDirection: "ltr",
|
||||
fitMode: "width",
|
||||
maxPageWidth: 900,
|
||||
readerZoom: 1.0,
|
||||
pageGap: true,
|
||||
optimizeContrast: false,
|
||||
offsetDoubleSpreads: false,
|
||||
@@ -126,9 +288,11 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
libraryCropCovers: true,
|
||||
libraryPageSize: 48,
|
||||
showNsfw: false,
|
||||
discordRpc: false,
|
||||
chapterSortDir: "desc",
|
||||
chapterSortMode: "source",
|
||||
chapterPageSize: 25,
|
||||
uiScale: 100,
|
||||
uiZoom: 1.0,
|
||||
compactSidebar: false,
|
||||
gpuAcceleration: true,
|
||||
serverUrl: "http://localhost:4567",
|
||||
@@ -139,24 +303,52 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
idleTimeoutMin: 5,
|
||||
splashCards: true,
|
||||
storageLimitGb: null,
|
||||
folders: [COMPLETED_FOLDER_DEFAULT],
|
||||
markReadOnNext: true,
|
||||
readerDebounceMs: 120,
|
||||
autoBookmark: true,
|
||||
theme: "dark",
|
||||
libraryBranches: true,
|
||||
renderLimit: 48,
|
||||
heroSlots: [null, null, null, null],
|
||||
mangaLinks: {},
|
||||
mangaPrefs: {},
|
||||
serverAuthUser: "",
|
||||
serverAuthPass: "",
|
||||
serverAuthMode: "NONE",
|
||||
socksProxyEnabled: false,
|
||||
socksProxyHost: "",
|
||||
socksProxyPort: "1080",
|
||||
socksProxyVersion: 5,
|
||||
socksProxyUsername: "",
|
||||
socksProxyPassword: "",
|
||||
flareSolverrEnabled: false,
|
||||
flareSolverrUrl: "http://localhost:8191",
|
||||
flareSolverrTimeout: 60,
|
||||
flareSolverrSessionName: "moku",
|
||||
flareSolverrSessionTtl: 15,
|
||||
flareSolverrFallback: false,
|
||||
appLockEnabled: false,
|
||||
appLockPin: "",
|
||||
customThemes: [],
|
||||
hiddenCategoryIds: [],
|
||||
defaultLibraryCategoryId: null,
|
||||
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
||||
nsfwAllowedSourceIds: [],
|
||||
nsfwBlockedSourceIds: [],
|
||||
libraryTabSort: {},
|
||||
libraryTabStatus: {},
|
||||
libraryTabFilters: {},
|
||||
extraScanDirs: [],
|
||||
serverDownloadsPath: "",
|
||||
serverLocalSourcePath: "",
|
||||
};
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
||||
const STORE_VERSION = 3;
|
||||
|
||||
const STORE_VERSION = 2;
|
||||
|
||||
// Fields reset to their DEFAULT_SETTINGS value on each version bump.
|
||||
// Add a key here whenever its default changes meaning between releases.
|
||||
const RESET_ON_UPGRADE: (keyof Settings)[] = [
|
||||
"serverBinary",
|
||||
"readerZoom",
|
||||
"uiZoom",
|
||||
];
|
||||
|
||||
function loadPersisted(): any {
|
||||
@@ -197,19 +389,21 @@ const saved = (() => {
|
||||
})();
|
||||
|
||||
function mergeSettings(saved: any): Settings {
|
||||
const userFolders: Folder[] = saved?.settings?.folders ?? [];
|
||||
const existingCompleted = userFolders.find(f => f.id === COMPLETED_FOLDER_ID);
|
||||
const completedFolder: Folder = existingCompleted
|
||||
? { ...COMPLETED_FOLDER_DEFAULT, mangaIds: existingCompleted.mangaIds }
|
||||
: COMPLETED_FOLDER_DEFAULT;
|
||||
const otherFolders = userFolders.filter(f => f.id !== COMPLETED_FOLDER_ID);
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...saved?.settings,
|
||||
folders: [completedFolder, ...otherFolders],
|
||||
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
||||
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
||||
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
|
||||
customThemes: saved?.settings?.customThemes ?? [],
|
||||
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
||||
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
||||
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
|
||||
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
||||
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
||||
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,15 +418,16 @@ function todayStr(): string {
|
||||
|
||||
const genId = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Store {
|
||||
navPage: NavPage = $state(saved?.navPage ?? "home");
|
||||
libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
|
||||
libraryFilter: LibraryFilter = $state("library");
|
||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
|
||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||
readingStats: ReadingStats = $state(mergeStats(saved));
|
||||
settings: Settings = $state(mergeSettings(saved));
|
||||
|
||||
readerSessionId: number = $state(0);
|
||||
genreFilter: string = $state("");
|
||||
searchPrefill: string = $state("");
|
||||
activeManga: Manga | null = $state(null);
|
||||
@@ -246,6 +441,11 @@ class Store {
|
||||
toasts: Toast[] = $state([]);
|
||||
activeChapter: Chapter | null = $state(null);
|
||||
activeChapterList: Chapter[] = $state([]);
|
||||
isFullscreen: boolean = $state(false);
|
||||
categories: Category[] = $state([]);
|
||||
discoverCache: Map<string, Manga[]> = $state(new Map());
|
||||
discoverLibraryIds: Set<number> = $state(new Set());
|
||||
discoverSrcOffset: number = $state(0);
|
||||
|
||||
constructor() {
|
||||
$effect.root(() => {
|
||||
@@ -253,12 +453,16 @@ class Store {
|
||||
$effect(() => { persist({ navPage: this.navPage }); });
|
||||
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
||||
$effect(() => { persist({ history: this.history }); });
|
||||
$effect(() => { persist({ readLog: this.readLog }); });
|
||||
$effect(() => { persist({ bookmarks: this.bookmarks }); });
|
||||
$effect(() => { persist({ markers: this.markers }); });
|
||||
$effect(() => { persist({ readingStats: this.readingStats }); });
|
||||
$effect(() => { persist({ settings: this.settings }); });
|
||||
});
|
||||
}
|
||||
|
||||
openReader(chapter: Chapter, chapterList: Chapter[]) {
|
||||
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||
if (manga) this.activeManga = manga;
|
||||
this.activeChapter = chapter;
|
||||
this.activeChapterList = chapterList;
|
||||
this.pageUrls = [];
|
||||
@@ -270,19 +474,31 @@ class Store {
|
||||
this.activeChapterList = [];
|
||||
this.pageUrls = [];
|
||||
this.pageNumber = 1;
|
||||
this.readerSessionId += 1;
|
||||
}
|
||||
|
||||
addHistory(entry: HistoryEntry) {
|
||||
const isNewChapter = !this.history.some(x => x.chapterId === entry.chapterId);
|
||||
|
||||
addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) {
|
||||
if (this.history[0]?.chapterId === entry.chapterId) {
|
||||
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||
this.history[0] = { ...this.history[0], readAt: entry.readAt };
|
||||
} else {
|
||||
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
|
||||
}
|
||||
|
||||
const uniqueChapters = new Set(this.history.map(e => e.chapterId));
|
||||
const uniqueManga = new Set(this.history.map(e => e.mangaId));
|
||||
if (completed) {
|
||||
const logEntry: ReadLogEntry = {
|
||||
mangaId: entry.mangaId,
|
||||
chapterId: entry.chapterId,
|
||||
readAt: entry.readAt,
|
||||
minutes,
|
||||
};
|
||||
this.readLog = [...this.readLog, logEntry].slice(-5000);
|
||||
}
|
||||
|
||||
const log = completed ? [...this.readLog] : this.readLog;
|
||||
|
||||
const uniqueChapters = new Set(log.map(e => e.chapterId));
|
||||
const uniqueManga = new Set(log.map(e => e.mangaId));
|
||||
const totalMinutes = log.reduce((sum, e) => sum + e.minutes, 0);
|
||||
|
||||
const today = todayStr();
|
||||
let { currentStreakDays, longestStreakDays, lastStreakDate } = this.readingStats;
|
||||
@@ -296,9 +512,9 @@ class Store {
|
||||
}
|
||||
|
||||
this.readingStats = {
|
||||
totalChaptersRead: Math.max(this.readingStats.totalChaptersRead, uniqueChapters.size),
|
||||
totalMangaRead: Math.max(this.readingStats.totalMangaRead, uniqueManga.size),
|
||||
totalMinutesRead: this.readingStats.totalMinutesRead + (isNewChapter ? AVG_MIN_PER_CHAPTER : 0),
|
||||
totalChaptersRead: uniqueChapters.size,
|
||||
totalMangaRead: uniqueManga.size,
|
||||
totalMinutesRead: totalMinutes,
|
||||
firstReadAt: this.readingStats.firstReadAt === 0 ? entry.readAt : this.readingStats.firstReadAt,
|
||||
lastReadAt: entry.readAt,
|
||||
currentStreakDays,
|
||||
@@ -307,36 +523,81 @@ class Store {
|
||||
};
|
||||
}
|
||||
|
||||
clearHistory() { this.history = []; }
|
||||
clearHistoryForManga(mangaId: number) { this.history = this.history.filter(x => x.mangaId !== mangaId); }
|
||||
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
||||
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
|
||||
this.bookmarks = [
|
||||
bookmark,
|
||||
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
|
||||
].slice(0, 200);
|
||||
}
|
||||
|
||||
removeBookmark(chapterId: number) {
|
||||
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
|
||||
}
|
||||
|
||||
clearBookmarks() {
|
||||
this.bookmarks = [];
|
||||
}
|
||||
|
||||
getBookmark(chapterId: number): BookmarkEntry | undefined {
|
||||
return this.bookmarks.find(b => b.chapterId === chapterId);
|
||||
}
|
||||
|
||||
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
|
||||
const id = genId();
|
||||
const marker: MarkerEntry = { ...entry, id, createdAt: Date.now() };
|
||||
this.markers = [marker, ...this.markers].slice(0, 2000);
|
||||
return id;
|
||||
}
|
||||
|
||||
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
|
||||
this.markers = this.markers.map(m =>
|
||||
m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m
|
||||
);
|
||||
}
|
||||
|
||||
removeMarker(id: string) {
|
||||
this.markers = this.markers.filter(m => m.id !== id);
|
||||
}
|
||||
|
||||
getMarkersForPage(chapterId: number, page: number): MarkerEntry[] {
|
||||
return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page);
|
||||
}
|
||||
|
||||
getMarkersForChapter(chapterId: number): MarkerEntry[] {
|
||||
return this.markers.filter(m => m.chapterId === chapterId);
|
||||
}
|
||||
|
||||
getMarkersForManga(mangaId: number): MarkerEntry[] {
|
||||
return this.markers.filter(m => m.mangaId === mangaId);
|
||||
}
|
||||
|
||||
clearMarkersForManga(mangaId: number) {
|
||||
this.markers = this.markers.filter(m => m.mangaId !== mangaId);
|
||||
}
|
||||
|
||||
clearHistory() { this.history = []; this.readLog = []; }
|
||||
|
||||
clearHistoryForManga(mangaId: number) {
|
||||
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
||||
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
||||
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
||||
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
||||
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
|
||||
this.readingStats = {
|
||||
...this.readingStats,
|
||||
totalChaptersRead: uniqueChapters.size,
|
||||
totalMangaRead: uniqueManga.size,
|
||||
totalMinutesRead: totalMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
wipeAllData() {
|
||||
this.history = [];
|
||||
this.readLog = [];
|
||||
this.markers = [];
|
||||
this.readingStats = { ...DEFAULT_READING_STATS };
|
||||
this.settings = { ...this.settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||
}
|
||||
|
||||
markMangaCompleted(mangaId: number) {
|
||||
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
|
||||
if (!folder) return;
|
||||
if (!folder.mangaIds.includes(mangaId))
|
||||
folder.mangaIds = [...folder.mangaIds, mangaId];
|
||||
}
|
||||
|
||||
unmarkMangaCompleted(mangaId: number) {
|
||||
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
|
||||
if (!folder) return;
|
||||
folder.mangaIds = folder.mangaIds.filter(id => id !== mangaId);
|
||||
}
|
||||
|
||||
isCompleted(mangaId: number): boolean {
|
||||
return this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds.includes(mangaId) ?? false;
|
||||
}
|
||||
|
||||
checkAndMarkCompleted(mangaId: number, chapters: Chapter[]) {
|
||||
if (!chapters.length) return;
|
||||
if (chapters.every(c => c.isRead)) this.markMangaCompleted(mangaId);
|
||||
else this.unmarkMangaCompleted(mangaId);
|
||||
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||
}
|
||||
|
||||
linkManga(idA: number, idB: number) {
|
||||
@@ -369,6 +630,7 @@ class Store {
|
||||
}
|
||||
|
||||
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
||||
setCategories(cats: Category[]) { this.categories = cats; }
|
||||
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
||||
setNavPage(next: NavPage) { this.navPage = next; }
|
||||
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
|
||||
@@ -384,75 +646,70 @@ class Store {
|
||||
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
|
||||
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
|
||||
|
||||
addFolder(name: string): string {
|
||||
const id = genId();
|
||||
this.settings = { ...this.settings, folders: [...this.settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] };
|
||||
return id;
|
||||
saveCustomTheme(theme: CustomTheme) {
|
||||
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
|
||||
const next = existing >= 0
|
||||
? this.settings.customThemes.map((t, i) => i === existing ? theme : t)
|
||||
: [...this.settings.customThemes, theme];
|
||||
this.settings = { ...this.settings, customThemes: next };
|
||||
}
|
||||
|
||||
removeFolder(id: string) {
|
||||
this.settings = { ...this.settings, folders: this.settings.folders.filter(f => f.id !== id || f.system) };
|
||||
deleteCustomTheme(id: string) {
|
||||
const next = this.settings.customThemes.filter(t => t.id !== id);
|
||||
const wasActive = this.settings.theme === id;
|
||||
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
|
||||
}
|
||||
|
||||
renameFolder(id: string, name: string) {
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
folders: this.settings.folders.map(f => f.id === id && !f.system ? { ...f, name: name.trim() } : f),
|
||||
};
|
||||
async checkAndMarkCompleted(
|
||||
mangaId: number,
|
||||
chaps: Chapter[],
|
||||
categories: Category[],
|
||||
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||
UPDATE_MANGA_CATEGORIES: string,
|
||||
UPDATE_MANGA?: string,
|
||||
): Promise<void> {
|
||||
if (!chaps.length) return;
|
||||
const allRead = chaps.every(c => c.isRead);
|
||||
const completed = categories.find(c => c.name === "Completed");
|
||||
if (!completed) return;
|
||||
if (allRead) {
|
||||
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error);
|
||||
if (UPDATE_MANGA) {
|
||||
await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
|
||||
}
|
||||
} else {
|
||||
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [], removeFrom: [completed.id] }).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
toggleFolderTab(id: string) {
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
folders: this.settings.folders.map(f => f.id === id ? { ...f, showTab: !f.showTab } : f),
|
||||
};
|
||||
toggleHiddenCategory(id: number) {
|
||||
const ids = this.settings.hiddenCategoryIds ?? [];
|
||||
const next = ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id];
|
||||
this.settings = { ...this.settings, hiddenCategoryIds: next };
|
||||
}
|
||||
|
||||
assignMangaToFolder(folderId: string, mangaId: number) {
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
folders: this.settings.folders.map(f =>
|
||||
f.id === folderId && !f.mangaIds.includes(mangaId)
|
||||
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
|
||||
: f
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
removeMangaFromFolder(folderId: string, mangaId: number) {
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
folders: this.settings.folders.map(f =>
|
||||
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) } : f
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
getMangaFolders(mangaId: number): Folder[] {
|
||||
return this.settings.folders.filter(f => f.mangaIds.includes(mangaId));
|
||||
clearDiscoverCache() {
|
||||
this.discoverCache = new Map();
|
||||
this.discoverLibraryIds = new Set();
|
||||
this.discoverSrcOffset++;
|
||||
}
|
||||
}
|
||||
|
||||
export const store = new Store();
|
||||
|
||||
// ── Function re-exports — zero call-site changes for actions ──────────────────
|
||||
|
||||
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
|
||||
export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); }
|
||||
export function closeReader() { store.closeReader(); }
|
||||
export function addHistory(entry: HistoryEntry) { store.addHistory(entry); }
|
||||
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
|
||||
export function clearHistory() { store.clearHistory(); }
|
||||
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
||||
export function wipeAllData() { store.wipeAllData(); }
|
||||
export function markMangaCompleted(mangaId: number) { store.markMangaCompleted(mangaId); }
|
||||
export function unmarkMangaCompleted(mangaId: number) { store.unmarkMangaCompleted(mangaId); }
|
||||
export function isCompleted(mangaId: number) { return store.isCompleted(mangaId); }
|
||||
export function checkAndMarkCompleted(mangaId: number, c: Chapter[]) { store.checkAndMarkCompleted(mangaId, c); }
|
||||
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
||||
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
|
||||
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
|
||||
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
||||
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
||||
export function dismissToast(id: string) { store.dismissToast(id); }
|
||||
export function setCategories(cats: Category[]) { store.setCategories(cats); }
|
||||
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
||||
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
||||
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
||||
@@ -467,10 +724,28 @@ export function setLibraryTagFilter(next: string[]) { store
|
||||
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||
export function resetKeybinds() { store.resetKeybinds(); }
|
||||
export function addFolder(name: string) { return store.addFolder(name); }
|
||||
export function removeFolder(id: string) { store.removeFolder(id); }
|
||||
export function renameFolder(id: string, name: string) { store.renameFolder(id, name); }
|
||||
export function toggleFolderTab(id: string) { store.toggleFolderTab(id); }
|
||||
export function assignMangaToFolder(folderId: string, mangaId: number) { store.assignMangaToFolder(folderId, mangaId); }
|
||||
export function removeMangaFromFolder(folderId: string, mangaId: number) { store.removeMangaFromFolder(folderId, mangaId); }
|
||||
export function getMangaFolders(mangaId: number) { return store.getMangaFolders(mangaId); }
|
||||
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
||||
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
|
||||
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
|
||||
export function clearBookmarks() { store.clearBookmarks(); }
|
||||
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
|
||||
export function addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { return store.addMarker(entry); }
|
||||
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { store.updateMarker(id, patch); }
|
||||
export function removeMarker(id: string) { store.removeMarker(id); }
|
||||
export function getMarkersForPage(chapterId: number, page: number) { return store.getMarkersForPage(chapterId, page); }
|
||||
export function getMarkersForChapter(chapterId: number) { return store.getMarkersForChapter(chapterId); }
|
||||
export function getMarkersForManga(mangaId: number) { return store.getMarkersForManga(mangaId); }
|
||||
export function clearMarkersForManga(mangaId: number) { store.clearMarkersForManga(mangaId); }
|
||||
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
|
||||
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
|
||||
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
|
||||
export async function checkAndMarkCompleted(
|
||||
mangaId: number,
|
||||
chaps: Chapter[],
|
||||
categories: Category[],
|
||||
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||
UPDATE_MANGA_CATEGORIES: string,
|
||||
UPDATE_MANGA?: string,
|
||||
): Promise<void> {
|
||||
return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||
}
|
||||
|
||||