Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 514910667b | |||
| 2e9939c4a9 | |||
| 581aea5694 | |||
| 72a88b10c8 | |||
| 371b4af73f | |||
| 634d32f372 | |||
| 4e6be5d9f5 | |||
| bb7256c4f8 | |||
| b12ff4cbaa | |||
| 63a829ddca | |||
| 94b14fb7f6 | |||
| bd2fd7a6d7 | |||
| 6634ad56d2 | |||
| 2eb8a7662e | |||
| 7dd4f52308 | |||
| 690f59c602 | |||
| c025336a7e | |||
| 86c78689df | |||
| 2d3a4d0e57 | |||
| 1a5c63a607 | |||
| 3f7102556b | |||
| f5a66ab5d1 | |||
| e41e8011be | |||
| 044c93a790 | |||
| e49df4501f | |||
| 4b97f4a6c9 | |||
| 005680394e | |||
| ecb4748414 | |||
| f0dc3446b2 | |||
| 8507c34b21 | |||
| 78da5915df | |||
| c0c486a53e | |||
| 236d6bcf08 | |||
| 2b140ae022 | |||
| 38d407092f | |||
| 12191dfcdf | |||
| 13a2f9ecb7 | |||
| c573c54318 | |||
| ff5fcc4fc0 | |||
| 1aad4a1ff0 | |||
| 68a9331b6f | |||
| 64f63ceaa2 | |||
| 6d835914ef | |||
| 10f5936dbd | |||
| 5ddbfdbd6d | |||
| 0ff148f720 | |||
| d98ca76036 | |||
| 35650481b0 | |||
| 8b16537c35 | |||
| 96639d2152 | |||
| 1c135a79ca | |||
| 6c11a9d53e | |||
| 5a2f88b806 | |||
| 75430305e6 | |||
| ea76b5fc26 | |||
| d5d9ff8b6e | |||
| 7c9182eb4b | |||
| 4d6ebe8804 | |||
| 49562c3f76 | |||
| 4a299f60ac | |||
| de397f2462 | |||
| af29cffdff | |||
| f840ae6413 | |||
| 6b8d4fc05f | |||
| 15079f7755 | |||
| 1a08d2415f | |||
| 7917491389 | |||
| 0b6e9fbbbb |
@@ -7,6 +7,9 @@ on:
|
|||||||
description: "Version to build (e.g. 0.4.0)"
|
description: "Version to build (e.g. 0.4.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
name: Build frontend
|
name: Build frontend
|
||||||
@@ -40,9 +43,6 @@ jobs:
|
|||||||
name: Tauri (macOS)
|
name: Tauri (macOS)
|
||||||
needs: frontend
|
needs: frontend
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -138,7 +138,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
# ── aarch64 build ──────────────────────────────────────────────────────
|
|
||||||
- name: Swap bundle for aarch64
|
- name: Swap bundle for aarch64
|
||||||
run: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
@@ -148,16 +147,8 @@ jobs:
|
|||||||
- name: Build Tauri app (aarch64)
|
- name: Build Tauri app (aarch64)
|
||||||
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
env:
|
env:
|
||||||
# Ad-hoc signing ("-") ships without a Developer ID.
|
|
||||||
# Gatekeeper will quarantine the app on other Macs — users must run:
|
|
||||||
# xattr -rd com.apple.quarantine Moku.app
|
|
||||||
# To fix this properly, set APPLE_SIGNING_IDENTITY to your
|
|
||||||
# "Developer ID Application: ..." cert name and add
|
|
||||||
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID /
|
|
||||||
# APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD secrets for notarisation.
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
# ── x86_64 build ───────────────────────────────────────────────────────
|
|
||||||
- name: Swap bundle for x86_64
|
- name: Swap bundle for x86_64
|
||||||
run: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
@@ -169,17 +160,35 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
# ── upload artifacts ───────────────────────────────────────────────────
|
- name: Upload macOS artifacts to release
|
||||||
- name: Upload arm64 .dmg
|
env:
|
||||||
uses: actions/upload-artifact@v4
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
name: moku-macos-arm64-${{ github.event.inputs.version }}
|
run: |
|
||||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
# Wait for the Windows workflow to have created the draft release
|
||||||
retention-days: 7
|
for i in $(seq 1 12); do
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||||
|
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||||
|
echo "Waiting for release to exist... attempt $i"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
- name: Upload x64 .dmg
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
uses: actions/upload-artifact@v4
|
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||||
with:
|
exit 1
|
||||||
name: moku-macos-x64-${{ github.event.inputs.version }}
|
fi
|
||||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
|
||||||
retention-days: 7
|
echo "Found release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
upload_asset() {
|
||||||
|
local file="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "Uploading $name..."
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
|
||||||
|
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
|
||||||
|
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
|
||||||
@@ -155,8 +155,12 @@ jobs:
|
|||||||
tagName: v${{ github.event.inputs.version }}
|
tagName: v${{ github.event.inputs.version }}
|
||||||
releaseName: Moku v${{ github.event.inputs.version }}
|
releaseName: Moku v${{ github.event.inputs.version }}
|
||||||
releaseBody: |
|
releaseBody: |
|
||||||
Windows installer for Moku v${{ github.event.inputs.version }}.
|
Moku v${{ github.event.inputs.version }}
|
||||||
Download the `.exe` file below to install or update.
|
|
||||||
|
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||||
|
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**Linux:** Download `moku.flatpak`
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright [2026] [@Youwes09]
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
@@ -99,16 +99,16 @@ exec /usr/lib/moku/jre/bin/java \
|
|||||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
install -Dm644 packaging/dev.moku.app.desktop \
|
install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \
|
||||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop"
|
||||||
install -Dm644 src-tauri/icons/32x32.png \
|
install -Dm644 src-tauri/icons/32x32.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.app.png"
|
||||||
install -Dm644 src-tauri/icons/128x128.png \
|
install -Dm644 src-tauri/icons/128x128.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.app.png"
|
||||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png"
|
||||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
|
||||||
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
<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-TagSearch.png" width="49%" alt="TagSearch" />
|
||||||
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
<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-Preview.png" width="49%" alt="Preview" />
|
||||||
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
||||||
@@ -37,13 +37,18 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||||
|
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, A–Z, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
|
||||||
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||||
|
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
||||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
|
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
||||||
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
||||||
|
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
- **Auto-updates** — in-app update checker with silent background notifications
|
- **Auto-updates** — in-app update checker with silent background notifications
|
||||||
|
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,39 @@
|
|||||||
Major Revisions:
|
Major Revisions:
|
||||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
|
- Moku-Share allows exporting of Manga
|
||||||
|
- Compressed Format (Storage)
|
||||||
|
- Import as Local-Source
|
||||||
|
- Takes existing Local-Source or Creates Own
|
||||||
|
|
||||||
Minor Revisions:
|
Minor Revisions:
|
||||||
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
|
||||||
|
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
- 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)
|
- 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:
|
Priority Bugs:
|
||||||
- Cache ALL Cover Pictures & Details for Manga in Library
|
- Fix Library-Refresh System (TESTING)
|
||||||
- Fix Library Build not Updating
|
|
||||||
- Check Auth System (Only Supports Basic-Auth)
|
|
||||||
|
|
||||||
|
- Suwayomi RESET
|
||||||
General/Misc Bugs:
|
- Allow User to Wipe Suwayomi (Scratch)
|
||||||
- Fix Highlightable Elements
|
- If Possible, Component based Wipe (Library, Etc)
|
||||||
- Investigate "egl:failed to create dri2 screen"
|
|
||||||
- Check Fonts/Design on Flatpak
|
|
||||||
- Fix Delete-All Crash (Deletes All but Cripples App)
|
|
||||||
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
|
||||||
|
|
||||||
|
|
||||||
In-Progress:
|
In-Progress:
|
||||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
|
- Working on 3D Display Cards
|
||||||
|
- Add Flathub Support (Pending Video)
|
||||||
|
|
||||||
- Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
|
- QOL Animations & Revamps
|
||||||
|
- Tracking Revamp
|
||||||
|
- Completely Revamp Tracking
|
||||||
|
|
||||||
|
|
||||||
|
- Fix Tracking Login
|
||||||
|
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Notes from last time:
|
||||||
Testing:
|
- Currently working on #42, just need to mount panel and fix button in reader
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
Before Width: | Height: | Size: 7.1 MiB After Width: | Height: | Size: 7.5 MiB |
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 914 KiB After Width: | Height: | Size: 947 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 648 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 609 KiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 5.0 MiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 940 KiB |
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem = { system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.7.1";
|
version = "0.9.0";
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
@@ -177,7 +177,7 @@ EOF
|
|||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
MANIFEST="$REPO/dev.moku.app.yml"
|
MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
|
||||||
|
|
||||||
echo "── Bumping versions ──"
|
echo "── Bumping versions ──"
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||||
@@ -195,7 +195,7 @@ EOF
|
|||||||
echo "Done"
|
echo "Done"
|
||||||
|
|
||||||
echo "── Repacking frontend-dist.tar.gz ──"
|
echo "── Repacking frontend-dist.tar.gz ──"
|
||||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO/dist" .
|
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||||
echo "sha256: $FRONTEND_SHA"
|
echo "sha256: $FRONTEND_SHA"
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ EOF
|
|||||||
--force-clean \
|
--force-clean \
|
||||||
"$REPO/build-dir" \
|
"$REPO/build-dir" \
|
||||||
"$MANIFEST"
|
"$MANIFEST"
|
||||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" dev.moku.app
|
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.Youwes09.Moku
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
echo "moku.flatpak created"
|
echo "moku.flatpak created"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
app-id: dev.moku.app
|
app-id: io.github.Youwes09.Moku
|
||||||
runtime: org.gnome.Platform
|
runtime: org.gnome.Platform
|
||||||
runtime-version: '48'
|
runtime-version: '48'
|
||||||
sdk: org.gnome.Sdk
|
sdk: org.gnome.Sdk
|
||||||
@@ -9,16 +9,22 @@ separate-locales: false
|
|||||||
|
|
||||||
finish-args:
|
finish-args:
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --socket=x11
|
|
||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
- --share=ipc
|
- --share=ipc
|
||||||
- --device=dri
|
- --device=dri
|
||||||
- --share=network
|
- --share=network
|
||||||
- --socket=session-bus
|
|
||||||
- --socket=system-bus
|
- --talk-name=org.freedesktop.Notifications
|
||||||
- --filesystem=home
|
- --talk-name=org.freedesktop.portal.Desktop
|
||||||
|
- --talk-name=org.freedesktop.portal.FileTransfer
|
||||||
|
|
||||||
|
- --talk-name=org.kde.StatusNotifierWatcher
|
||||||
|
- --talk-name=com.canonical.AppMenu.Registrar
|
||||||
|
- --talk-name=com.canonical.indicator.application
|
||||||
|
|
||||||
|
- --filesystem=xdg-run/discord-ipc-0:ro
|
||||||
- --filesystem=xdg-data/moku:create
|
- --filesystem=xdg-data/moku:create
|
||||||
- --talk-name=org.freedesktop.Flatpak
|
- --filesystem=xdg-download
|
||||||
|
|
||||||
build-options:
|
build-options:
|
||||||
append-path: /usr/lib/sdk/rust-stable/bin
|
append-path: /usr/lib/sdk/rust-stable/bin
|
||||||
@@ -33,13 +39,10 @@ modules:
|
|||||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
|
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
|
||||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
|
||||||
dest-filename: jdk.tar.gz
|
dest-filename: jdk.tar.gz
|
||||||
|
|
||||||
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
|
|
||||||
# exits just that thread instead of killing the whole JVM. Official Suwayomi
|
|
||||||
# fix for headless environments. Source inlined to avoid upstream drift.
|
|
||||||
- name: catch-abort
|
- name: catch-abort
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -120,7 +123,6 @@ modules:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
||||||
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
@@ -138,8 +140,6 @@ modules:
|
|||||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
|
|
||||||
# that thread instead of crashing the whole JVM process.
|
|
||||||
export LD_PRELOAD="/app/lib/catch_abort.so"
|
export LD_PRELOAD="/app/lib/catch_abort.so"
|
||||||
|
|
||||||
exec /app/jre/bin/java \
|
exec /app/jre/bin/java \
|
||||||
@@ -171,17 +171,19 @@ modules:
|
|||||||
- tar -xzf frontend-dist.tar.gz
|
- tar -xzf frontend-dist.tar.gz
|
||||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||||
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
|
- install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop
|
||||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.png
|
||||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
- install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
- type: dir
|
- type: git
|
||||||
path: .
|
url: https://github.com/Youwes09/Moku.git
|
||||||
|
tag: v0.8.0
|
||||||
|
commit: c573c543187cbd1ca1455b25d6bce0fc62666341
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: d3ebde4d39e3de61420b78a9506df1a5c77c14d705e42662a45a2179bc96030e
|
sha256: d547893e1b76f1678df131d46b0964e9ef34e54e8571d5c435a22cef7316f75a
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<component type="desktop-application">
|
|
||||||
<id>dev.moku.app</id>
|
|
||||||
<metadata_license>MIT</metadata_license>
|
|
||||||
<project_license>MIT</project_license>
|
|
||||||
|
|
||||||
<name>Moku</name>
|
|
||||||
<summary>Manga reader powered by Suwayomi</summary>
|
|
||||||
|
|
||||||
<description>
|
|
||||||
<p>
|
|
||||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
|
||||||
providing a clean native interface for browsing, reading, and managing your
|
|
||||||
manga library across hundreds of sources.
|
|
||||||
</p>
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
|
|
||||||
|
|
||||||
<url type="homepage">https://github.com/shozikan/Moku</url>
|
|
||||||
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
|
|
||||||
|
|
||||||
<provides>
|
|
||||||
<binary>moku</binary>
|
|
||||||
</provides>
|
|
||||||
|
|
||||||
<content_rating type="oars-1.1" />
|
|
||||||
|
|
||||||
<releases>
|
|
||||||
<release version="0.4.0" date="2025-03-22">
|
|
||||||
<description>
|
|
||||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
</releases>
|
|
||||||
</component>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Name=Moku
|
Name=Moku
|
||||||
Comment=Manga reader powered by Suwayomi
|
Comment=Manga reader powered by Suwayomi
|
||||||
Exec=moku
|
Exec=moku
|
||||||
Icon=dev.moku.app
|
Icon=io.github.Youwes09.Moku
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Graphics;Viewer;
|
Categories=Graphics;Viewer;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>io.github.Youwes09.Moku</id>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
|
||||||
|
<name>Moku</name>
|
||||||
|
<summary>Manga reader powered by Suwayomi</summary>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||||
|
providing a clean native interface for browsing, reading, and managing your
|
||||||
|
manga library across hundreds of sources.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Features include library management, chapter tracking, extension support,
|
||||||
|
reading history, notifications, and Discord Rich Presence integration.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable>
|
||||||
|
|
||||||
|
<url type="homepage">https://github.com/Youwes09/Moku</url>
|
||||||
|
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||||
|
<caption>Home screen showing your manga library</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||||
|
<caption>Built-in manga reader</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||||
|
<caption>Discover new manga across hundreds of sources</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||||
|
<caption>Download manager</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||||
|
<caption>Settings</caption>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>moku</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.8.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Latest release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.4.0" date="2025-03-22">
|
||||||
|
<description>
|
||||||
|
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.7.1"
|
version = "0.9.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -17,9 +17,9 @@ tauri-build = { version = "2.0", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0", features = [] }
|
tauri = { version = "2.0", features = [] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-os = "2.3.2"
|
tauri-plugin-os = "2.3.2"
|
||||||
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -26,9 +26,6 @@
|
|||||||
"core:window:allow-inner-position",
|
"core:window:allow-inner-position",
|
||||||
"core:window:allow-outer-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:default",
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"http:default",
|
"http:default",
|
||||||
|
|||||||
@@ -53,12 +53,9 @@ fn strip_unc(path: PathBuf) -> PathBuf {
|
|||||||
|
|
||||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||||
if !downloads_path.trim().is_empty() {
|
if !downloads_path.trim().is_empty() {
|
||||||
return PathBuf::from(downloads_path);
|
return PathBuf::from(downloads_path.trim());
|
||||||
}
|
}
|
||||||
let base = std::env::var("XDG_DATA_HOME")
|
suwayomi_data_dir().join("downloads")
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
|
||||||
base.join("Tachidesk").join("downloads")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -272,7 +269,7 @@ fn suwayomi_data_dir() -> PathBuf {
|
|||||||
{
|
{
|
||||||
dirs::data_dir()
|
dirs::data_dir()
|
||||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||||
.join("dev.moku.app/tachidesk")
|
.join("io.github.Youwes09.Moku.app/tachidesk")
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
@@ -327,6 +324,22 @@ fn resolve_server_binary(
|
|||||||
do_log(log, "[resolve] user path not found, falling through");
|
do_log(log, "[resolve] user path not found, falling through");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(bin_dir) = exe.parent() {
|
||||||
|
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||||
|
let p = bin_dir.join(name);
|
||||||
|
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: vec![],
|
||||||
|
working_dir: Some(bin_dir.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
let resource_dir = {
|
let resource_dir = {
|
||||||
let raw = app.path().resource_dir().unwrap_or_default();
|
let raw = app.path().resource_dir().unwrap_or_default();
|
||||||
@@ -393,30 +406,102 @@ fn resolve_server_binary(
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
let macos_dir = resource_dir.parent().map(|p| p.join("MacOS")).unwrap_or_default();
|
let contents_dir = resource_dir
|
||||||
|
.parent()
|
||||||
|
.unwrap_or(&resource_dir)
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
let candidates = [
|
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
|
||||||
"suwayomi-server",
|
|
||||||
|
const NATIVE_NAMES: &[&str] = &[
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
|
"suwayomi-server",
|
||||||
"suwayomi-launcher",
|
"suwayomi-launcher",
|
||||||
"suwayomi-launcher.sh",
|
"suwayomi-launcher.sh",
|
||||||
"tachidesk-server",
|
"tachidesk-server",
|
||||||
];
|
];
|
||||||
|
|
||||||
for search_dir in &[&macos_dir, &resource_dir] {
|
let mut found_binary: Option<ServerInvocation> = None;
|
||||||
for name in &candidates {
|
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
||||||
let p = search_dir.join(name);
|
|
||||||
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
'outer: for depth in 0u8..=8 {
|
||||||
if p.exists() {
|
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
||||||
return Ok(ServerInvocation {
|
.min_depth(depth as usize)
|
||||||
bin: p.to_string_lossy().into_owned(),
|
.max_depth(depth as usize)
|
||||||
args: vec![],
|
.into_iter()
|
||||||
working_dir: None,
|
.filter_map(|e| e.ok())
|
||||||
});
|
.filter(|e| e.file_type().is_dir())
|
||||||
|
.map(|e| e.into_path())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for dir in &entries {
|
||||||
|
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
|
||||||
|
|
||||||
|
for name in NATIVE_NAMES {
|
||||||
|
let p = dir.join(name);
|
||||||
|
if p.exists() {
|
||||||
|
do_log(log, &format!("[resolve] found native binary: {:?}", p));
|
||||||
|
found_binary = Some(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: vec![],
|
||||||
|
working_dir: Some(dir.clone()),
|
||||||
|
});
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found_java.is_none() {
|
||||||
|
let java_exe = dir.join("bin").join("java");
|
||||||
|
if java_exe.exists() {
|
||||||
|
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
||||||
|
let mut search = dir.as_path();
|
||||||
|
'jar: for _ in 0..5 {
|
||||||
|
if let Ok(rd) = std::fs::read_dir(search) {
|
||||||
|
for entry in rd.filter_map(|e| e.ok()) {
|
||||||
|
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||||
|
let jar = entry.path();
|
||||||
|
do_log(log, &format!("[resolve] found jar: {:?}", jar));
|
||||||
|
found_java = Some((java_exe.clone(), jar));
|
||||||
|
break 'jar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bin_sibling = search.join("bin");
|
||||||
|
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
||||||
|
for entry in rd.filter_map(|e| e.ok()) {
|
||||||
|
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||||
|
let jar = entry.path();
|
||||||
|
do_log(log, &format!("[resolve] found jar in bin/: {:?}", jar));
|
||||||
|
found_java = Some((java_exe.clone(), jar));
|
||||||
|
break 'jar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match search.parent() {
|
||||||
|
Some(p) => search = p,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(inv) = found_binary {
|
||||||
|
return Ok(inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((java, jar)) = found_java {
|
||||||
|
let working_dir = jar.parent().map(|p| p.to_path_buf());
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||||
|
working_dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
do_log(log, "[resolve] macOS scan found nothing in bundle");
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||||
@@ -458,11 +543,13 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
|
|||||||
e
|
e
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let rootdir_flag = format!(
|
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
let rootdir_flag = format!(
|
||||||
data_dir.to_string_lossy()
|
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||||
);
|
data_dir.to_string_lossy()
|
||||||
invocation.args.insert(0, rootdir_flag);
|
);
|
||||||
|
invocation.args.insert(0, rootdir_flag);
|
||||||
|
}
|
||||||
|
|
||||||
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
|
|
||||||
@@ -534,32 +621,62 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
|
async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
use tauri_plugin_http::reqwest;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
let updater = app.updater().map_err(|e| e.to_string())?;
|
let client = reqwest::Client::builder()
|
||||||
let update = updater.check().await.map_err(|e| e.to_string())?;
|
.user_agent("Moku")
|
||||||
|
.build()
|
||||||
let Some(update) = update else {
|
|
||||||
return Err("No update available.".into());
|
|
||||||
};
|
|
||||||
|
|
||||||
let app_clone = app.clone();
|
|
||||||
update
|
|
||||||
.download_and_install(
|
|
||||||
move |downloaded, total| {
|
|
||||||
let _ = app_clone.emit("update-progress", UpdateProgress { downloaded: downloaded as u64, total });
|
|
||||||
},
|
|
||||||
|| {},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let url = format!("https://api.github.com/repos/Youwes09/Moku/releases/tags/{}", tag);
|
||||||
|
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Asset { name: String, browser_download_url: String, size: u64 }
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Release { assets: Vec<Asset> }
|
||||||
|
|
||||||
|
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||||
|
let release: Release = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let asset = release.assets
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.name.ends_with("_x64-setup.exe"))
|
||||||
|
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
|
||||||
|
|
||||||
|
let total = if asset.size > 0 { Some(asset.size) } else { None };
|
||||||
|
let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let tmp_path = std::env::temp_dir().join(&asset.name);
|
||||||
|
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
|
||||||
|
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
|
||||||
|
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
|
||||||
|
}
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
std::process::Command::new(&tmp_path)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let _ = app.emit("update-launching", ());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,16 +686,131 @@ fn restart_app(app: tauri::AppHandle) {
|
|||||||
tauri::process::restart(&app.env());
|
tauri::process::restart(&app.env());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_path(path: String) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let p = strip_unc(std::path::PathBuf::from(path.trim()));
|
||||||
|
std::process::Command::new("explorer")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let p = std::path::Path::new(path.trim());
|
||||||
|
std::process::Command::new("open")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let p = std::path::Path::new(path.trim());
|
||||||
|
std::process::Command::new("xdg-open")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Downloads Folder")
|
||||||
|
.blocking_pick_folder()
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||||
|
app.path().app_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("backups")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let filename = format!("moku-backup-{}.json", now);
|
||||||
|
|
||||||
|
let path = app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Save Moku app data backup")
|
||||||
|
.set_file_name(&filename)
|
||||||
|
.blocking_save_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
let dest = PathBuf::from(path.to_string());
|
||||||
|
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(dest.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let path = app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Open Moku app data backup")
|
||||||
|
.blocking_pick_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
let src = PathBuf::from(path.to_string());
|
||||||
|
let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
|
||||||
|
let backup_dir = moku_backup_dir(&app);
|
||||||
|
std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now));
|
||||||
|
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
entries.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for old in entries.iter().take(entries.len().saturating_sub(5)) {
|
||||||
|
let _ = std::fs::remove_file(old.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||||
|
moku_backup_dir(&app).to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_discord_rpc::init())
|
.plugin(tauri_plugin_discord_rpc::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_storage_info,
|
get_storage_info,
|
||||||
@@ -592,6 +824,12 @@ pub fn run() {
|
|||||||
list_releases,
|
list_releases,
|
||||||
download_and_install_update,
|
download_and_install_update,
|
||||||
restart_app,
|
restart_app,
|
||||||
|
open_path,
|
||||||
|
pick_downloads_folder,
|
||||||
|
export_app_data,
|
||||||
|
import_app_data,
|
||||||
|
auto_backup_app_data,
|
||||||
|
get_auto_backup_dir,
|
||||||
])
|
])
|
||||||
.setup(|_app| Ok(()))
|
.setup(|_app| Ok(()))
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.7.1",
|
"version": "0.9.0",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "io.github.Youwes09.Moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build"
|
"beforeBuildCommand": "pnpm build"
|
||||||
|
|||||||
@@ -1,59 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
import { gql } from "./lib/client";
|
import { store, setActiveDownloads } from "@store/state.svelte";
|
||||||
import logoUrl from "./assets/moku-icon-splash.svg";
|
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||||
import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
|
import { boot, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
||||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
||||||
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
import { applyTheme } from "@core/theme";
|
||||||
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
|
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
import { checkForUpdateSilently } from "@core/updater";
|
||||||
import Layout from "./components/chrome/Layout.svelte";
|
import Layout from "@shared/chrome/Layout.svelte";
|
||||||
import Reader from "./components/reader/Reader.svelte";
|
import Reader from "@features/reader/components/Reader.svelte";
|
||||||
import Settings from "./components/settings/Settings.svelte";
|
import Settings from "@features/settings/components/Settings.svelte";
|
||||||
import ThemeEditor from "./components/settings/ThemeEditor.svelte";
|
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
|
||||||
import TitleBar from "./components/chrome/TitleBar.svelte";
|
import TitleBar from "@shared/chrome/TitleBar.svelte";
|
||||||
import Toaster from "./components/chrome/Toaster.svelte";
|
import Toaster from "@shared/chrome/Toaster.svelte";
|
||||||
import SplashScreen from "./components/chrome/SplashScreen.svelte";
|
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
|
||||||
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
import MangaPreview from "@shared/manga/MangaPreview.svelte";
|
||||||
|
import AuthGate from "@shared/chrome/AuthGate.svelte";
|
||||||
|
|
||||||
let themeStyleEl: HTMLStyleElement | null = null;
|
const win = getCurrentWindow();
|
||||||
|
void platform();
|
||||||
|
|
||||||
$effect(() => {
|
let appReady = $state(false);
|
||||||
const themeId = store.settings.theme ?? "dark";
|
let idle = $state(false);
|
||||||
const isCustom = themeId.startsWith("custom:");
|
let devSplash = $state(false);
|
||||||
|
|
||||||
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 themeEditorOpen = $state(false);
|
||||||
let themeEditorEditId = $state<string | null>(null);
|
let themeEditorEditId = $state<string | null>(null);
|
||||||
@@ -68,229 +41,16 @@
|
|||||||
themeEditorEditId = null;
|
themeEditorEditId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 10;
|
$effect(() => { void store.settings.theme; applyTheme(); });
|
||||||
const win = getCurrentWindow();
|
$effect(() => { void store.settings.uiZoom; applyZoom(); });
|
||||||
|
$effect(() => mountZoomKey());
|
||||||
let serverProbeOk = $state(false);
|
|
||||||
let appReady = $state(false);
|
|
||||||
let failed = $state(false);
|
|
||||||
let notConfigured = $state(false);
|
|
||||||
let idle = $state(false);
|
|
||||||
let devSplash = $state(false);
|
|
||||||
|
|
||||||
let loginRequired = $state(false);
|
|
||||||
let loginUser = $state(store.settings.serverAuthUser ?? "");
|
|
||||||
let loginPass = $state("");
|
|
||||||
let loginError = $state<string | null>(null);
|
|
||||||
let loginBusy = $state(false);
|
|
||||||
let unsupportedMode = $state(false);
|
|
||||||
|
|
||||||
let platformScale = $state(1.0);
|
|
||||||
let _appliedZoom = -1;
|
|
||||||
let _vhRafId: number | null = null;
|
|
||||||
|
|
||||||
function applyZoom() {
|
|
||||||
const uiZoom = store.settings.uiZoom ?? 1.0;
|
|
||||||
if (uiZoom === _appliedZoom) return;
|
|
||||||
_appliedZoom = uiZoom;
|
|
||||||
|
|
||||||
const pct = uiZoom * 100;
|
|
||||||
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
|
|
||||||
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
|
|
||||||
document.documentElement.style.zoom = `${pct}%`;
|
|
||||||
|
|
||||||
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
|
|
||||||
_vhRafId = requestAnimationFrame(() => {
|
|
||||||
_vhRafId = null;
|
|
||||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let prevQueue: DownloadQueueItem[] = [];
|
|
||||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let pollInterval: ReturnType<typeof setInterval>;
|
|
||||||
let unlistenDownload: (() => void) | undefined;
|
|
||||||
|
|
||||||
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
|
||||||
for (const item of prev) {
|
|
||||||
if (item.state !== "DOWNLOADING") continue;
|
|
||||||
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
|
||||||
const manga = item.chapter.manga;
|
|
||||||
addToast({ kind: "success", title: "Chapter downloaded",
|
|
||||||
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
|
||||||
duration: 4000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyQueue(next: DownloadQueueItem[]) {
|
|
||||||
detectCompletions(prevQueue, next);
|
|
||||||
prevQueue = next;
|
|
||||||
setActiveDownloads(next.map(item => ({
|
|
||||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetIdle() {
|
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
|
||||||
if (idle) return;
|
|
||||||
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
|
||||||
if (ms === 0) return;
|
|
||||||
idleTimer = setTimeout(() => idle = true, ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
const idleEvents = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!appReady) return;
|
if (!appReady) return;
|
||||||
idleEvents.forEach(e => window.addEventListener(e, resetIdle, { passive: true }));
|
return mountIdleDetection(
|
||||||
resetIdle();
|
() => { idle = true; },
|
||||||
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
|
() => { if (idle) idle = false; },
|
||||||
});
|
);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void store.settings.uiZoom;
|
|
||||||
applyZoom();
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
|
||||||
poll();
|
|
||||||
pollInterval = setInterval(poll, 2000);
|
|
||||||
return () => clearInterval(pollInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function checkForUpdateSilently() {
|
|
||||||
try {
|
|
||||||
const [currentVersion, releases] = await Promise.all([
|
|
||||||
getVersion(),
|
|
||||||
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
|
||||||
if (!valid.length) return;
|
|
||||||
|
|
||||||
const parse = (tag: string): number[] =>
|
|
||||||
tag.replace(/^v/, "").split(".").map(Number);
|
|
||||||
|
|
||||||
const compare = (a: number[], b: number[]): number => {
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const latestTag = valid
|
|
||||||
.map(r => r.tag_name)
|
|
||||||
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
|
||||||
.replace(/^v/, "");
|
|
||||||
|
|
||||||
const isNewer = compare(parse(latestTag), parse(currentVersion)) < 0;
|
|
||||||
if (isNewer) {
|
|
||||||
addToast({
|
|
||||||
kind: "info",
|
|
||||||
title: `Update available — v${latestTag}`,
|
|
||||||
body: "Open Settings → About to install.",
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelProbe = false;
|
|
||||||
|
|
||||||
function startProbe() {
|
|
||||||
cancelProbe = false;
|
|
||||||
failed = false;
|
|
||||||
loginRequired = false;
|
|
||||||
let tries = 0;
|
|
||||||
|
|
||||||
async function probe() {
|
|
||||||
if (cancelProbe) return;
|
|
||||||
tries++;
|
|
||||||
const result = await probeServer();
|
|
||||||
if (cancelProbe) return;
|
|
||||||
|
|
||||||
if (result === "ok") {
|
|
||||||
serverProbeOk = true;
|
|
||||||
loginRequired = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result === "auth_required") {
|
|
||||||
serverProbeOk = true;
|
|
||||||
const savedUser = store.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const savedPass = store.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
if (savedUser && savedPass) {
|
|
||||||
try {
|
|
||||||
await loginBasic(savedUser, savedPass);
|
|
||||||
loginRequired = false;
|
|
||||||
return;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
loginRequired = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result === "unsupported_mode") {
|
|
||||||
serverProbeOk = true;
|
|
||||||
unsupportedMode = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
|
|
||||||
setTimeout(probe, 750);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(probe, 800);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
|
||||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
|
||||||
|
|
||||||
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
|
|
||||||
applyZoom();
|
|
||||||
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
|
||||||
|
|
||||||
const unlistenResize = await win.onResized(async () => {
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlistenScale = await win.onScaleChanged(async (event) => {
|
|
||||||
platformScale = event.payload.scaleFactor;
|
|
||||||
applyZoom();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (store.settings.autoStartServer) {
|
|
||||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
|
||||||
if (err?.kind === "NotConfigured") {
|
|
||||||
notConfigured = true;
|
|
||||||
} else {
|
|
||||||
console.warn("Could not start server:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startProbe();
|
|
||||||
|
|
||||||
type P = { chapterId: number; mangaId: number; progress: number }[];
|
|
||||||
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelProbe = true;
|
|
||||||
unlistenResize();
|
|
||||||
unlistenScale();
|
|
||||||
destroyRpc();
|
|
||||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
|
||||||
unlistenDownload?.();
|
|
||||||
delete (window as any).__mokuShowSplash;
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -309,139 +69,90 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!store.activeChapter) {
|
if (!store.activeChapter && store.settings.discordRpc) setIdle();
|
||||||
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(() => {
|
$effect(() => {
|
||||||
window.addEventListener("keydown", handleZoomKey);
|
const next = downloadStore.queue.slice();
|
||||||
return () => window.removeEventListener("keydown", handleZoomKey);
|
downloadStore.detectTransitions(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleLogin() {
|
onMount(async () => {
|
||||||
if (!loginUser.trim() || !loginPass.trim()) {
|
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||||
loginError = "Username and password are required";
|
(window as any).__mokuShowSplash = () => { devSplash = true; };
|
||||||
return;
|
|
||||||
}
|
applyZoom();
|
||||||
loginBusy = true;
|
|
||||||
loginError = null;
|
store.isFullscreen = await win.isFullscreen();
|
||||||
try {
|
|
||||||
await loginBasic(loginUser.trim(), loginPass.trim());
|
const unlistenResize = await win.onResized(async () => {
|
||||||
loginRequired = false;
|
store.isFullscreen = await win.isFullscreen();
|
||||||
loginPass = "";
|
});
|
||||||
loginError = null;
|
|
||||||
appReady = true;
|
const unlistenScale = await win.onScaleChanged(async () => {
|
||||||
} catch (e: any) {
|
applyZoom();
|
||||||
loginError = e?.message ?? "Login failed";
|
});
|
||||||
} finally {
|
|
||||||
loginBusy = false;
|
if (store.settings.autoStartServer) {
|
||||||
}
|
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||||
}
|
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
||||||
|
else console.warn("Could not start server:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleRetry() {
|
|
||||||
failed = false;
|
|
||||||
notConfigured = false;
|
|
||||||
serverProbeOk = false;
|
|
||||||
loginRequired = false;
|
|
||||||
unsupportedMode = false;
|
|
||||||
startProbe();
|
startProbe();
|
||||||
}
|
|
||||||
|
|
||||||
function handleBypass() {
|
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||||
cancelProbe = true;
|
"download-progress",
|
||||||
serverProbeOk = true;
|
e => setActiveDownloads(e.payload),
|
||||||
loginRequired = false;
|
);
|
||||||
unsupportedMode = false;
|
|
||||||
appReady = true;
|
await downloadStore.poll();
|
||||||
}
|
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopProbe();
|
||||||
|
clearInterval(dlInterval);
|
||||||
|
unlistenResize();
|
||||||
|
unlistenScale();
|
||||||
|
unlistenDownload();
|
||||||
|
destroyRpc();
|
||||||
|
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
||||||
|
delete (window as any).__mokuShowSplash;
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if devSplash}
|
{#if devSplash}
|
||||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||||
{:else if !appReady && !loginRequired && !unsupportedMode}
|
|
||||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
{:else if !appReady && !boot.loginRequired && !boot.unsupportedMode}
|
||||||
|
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
||||||
|
failed={boot.failed} notConfigured={boot.notConfigured}
|
||||||
showCards={store.settings.splashCards ?? true}
|
showCards={store.settings.splashCards ?? true}
|
||||||
onReady={() => { appReady = true; }}
|
onReady={() => { appReady = true; }}
|
||||||
onRetry={handleRetry}
|
onRetry={retryBoot}
|
||||||
onBypass={handleBypass} />
|
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
||||||
{:else if unsupportedMode}
|
|
||||||
|
{:else if boot.unsupportedMode || boot.loginRequired}
|
||||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
<div class="auth-overlay">
|
<AuthGate onReady={() => { appReady = true; }} />
|
||||||
<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}
|
{:else}
|
||||||
|
{#if idle && !store.activeChapter}
|
||||||
|
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||||
|
onDismiss={() => { idle = false; }} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div id="app-shell" class="root">
|
<div id="app-shell" class="root">
|
||||||
{#if idle && !store.activeChapter}
|
|
||||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
|
||||||
onDismiss={() => { idle = false; resetIdle(); }} />
|
|
||||||
{/if}
|
|
||||||
{#if !store.activeChapter}<TitleBar />{/if}
|
{#if !store.activeChapter}<TitleBar />{/if}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
||||||
{#if themeEditorOpen}
|
{#if themeEditorOpen}
|
||||||
<ThemeEditor
|
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
|
||||||
bind:editingId={themeEditorEditId}
|
|
||||||
onClose={closeThemeEditor}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
<MangaPreview />
|
<MangaPreview />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
@@ -449,29 +160,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.content { flex: 1; 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>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { store } from "../store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { fetchAuthenticated } from "./auth";
|
import { fetchAuthenticated } from "../core/auth";
|
||||||
|
|
||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
@@ -8,20 +8,16 @@ function getServerUrl(): string {
|
|||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
|
||||||
|
|
||||||
export function plainThumbUrl(path: string): string {
|
export function plainThumbUrl(path: string): string {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path.startsWith("http")) return path;
|
if (path.startsWith("http")) return path;
|
||||||
return `${getServerUrl()}${path}`;
|
return `${getServerUrl()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function thumbUrl(path: string): string {
|
export const thumbUrl = plainThumbUrl;
|
||||||
return plainThumbUrl(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GQLResponse<T> {
|
interface GQLResponse<T> {
|
||||||
data: T;
|
data: T;
|
||||||
errors?: { message: string }[];
|
errors?: { message: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,14 +33,13 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWithRetry(
|
async function fetchWithRetry(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
retries = 3,
|
retries = 3,
|
||||||
delayMs = 300,
|
delayMs = 300,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
try {
|
try {
|
||||||
@@ -53,8 +48,7 @@ async function fetchWithRetry(
|
|||||||
return res;
|
return res;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.authRequired) throw e;
|
if (e?.authRequired) throw e;
|
||||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
|
||||||
if (i === retries - 1) throw e;
|
if (i === retries - 1) throw e;
|
||||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||||
}
|
}
|
||||||
@@ -63,23 +57,19 @@ async function fetchWithRetry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function gql<T>(
|
export async function gql<T>(
|
||||||
query: string,
|
query: string,
|
||||||
variables?: Record<string, unknown>,
|
variables?: Record<string, unknown>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchWithRetry(gqlUrl(), {
|
const res = await fetchWithRetry(
|
||||||
method: "POST",
|
`${getServerUrl()}/api/graphql`,
|
||||||
headers: { "Content-Type": "application/json" },
|
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
||||||
body: JSON.stringify({ query, variables }),
|
signal,
|
||||||
}, signal);
|
);
|
||||||
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
|
||||||
const json: GQLResponse<T> = await res.json();
|
const json: GQLResponse<T> = await res.json();
|
||||||
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||||
|
|
||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export * from "./client";
|
||||||
|
export * from "./queries/manga";
|
||||||
|
export * from "./queries/chapters";
|
||||||
|
export * from "./queries/downloads";
|
||||||
|
export * from "./queries/extensions";
|
||||||
|
export * from "./queries/tracking";
|
||||||
|
export * from "./mutations/manga";
|
||||||
|
export * from "./mutations/chapters";
|
||||||
|
export * from "./mutations/downloads";
|
||||||
|
export * from "./mutations/extensions";
|
||||||
|
export * from "./mutations/tracking";
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export const FETCH_CHAPTERS = `
|
||||||
|
mutation FetchChapters($mangaId: Int!) {
|
||||||
|
fetchChapters(input: { mangaId: $mangaId }) {
|
||||||
|
chapters {
|
||||||
|
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||||
|
pageCount mangaId uploadDate realUrl lastPageRead scanlator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_CHAPTER_PAGES = `
|
||||||
|
mutation FetchChapterPages($chapterId: Int!) {
|
||||||
|
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MARK_CHAPTER_READ = `
|
||||||
|
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||||
|
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||||
|
chapter { id isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MARK_CHAPTERS_READ = `
|
||||||
|
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
|
||||||
|
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
|
||||||
|
chapters { id isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CHAPTERS_PROGRESS = `
|
||||||
|
mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) {
|
||||||
|
updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) {
|
||||||
|
chapters { id isRead isBookmarked lastPageRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_DOWNLOADED_CHAPTERS = `
|
||||||
|
mutation DeleteDownloadedChapters($ids: [Int!]!) {
|
||||||
|
deleteDownloadedChapters(input: { ids: $ids }) {
|
||||||
|
chapters { id isDownloaded }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const QUEUE_FRAGMENT = `
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress state tries
|
||||||
|
chapter {
|
||||||
|
id name pageCount mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ENQUEUE_DOWNLOAD = `
|
||||||
|
mutation EnqueueDownload($chapterId: Int!) {
|
||||||
|
enqueueChapterDownload(input: { id: $chapterId }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ENQUEUE_CHAPTERS_DOWNLOAD = `
|
||||||
|
mutation EnqueueChaptersDownload($chapterIds: [Int!]!) {
|
||||||
|
enqueueChapterDownloads(input: { ids: $chapterIds }) {
|
||||||
|
downloadStatus { state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DEQUEUE_DOWNLOAD = `
|
||||||
|
mutation DequeueDownload($chapterId: Int!) {
|
||||||
|
dequeueChapterDownload(input: { id: $chapterId }) {
|
||||||
|
downloadStatus { state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DEQUEUE_CHAPTERS_DOWNLOAD = `
|
||||||
|
mutation DequeueChaptersDownload($chapterIds: [Int!]!) {
|
||||||
|
dequeueChapterDownloads(input: { ids: $chapterIds }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REORDER_DOWNLOAD = `
|
||||||
|
mutation ReorderDownload($chapterId: Int!, $to: Int!) {
|
||||||
|
reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const START_DOWNLOADER = `
|
||||||
|
mutation StartDownloader {
|
||||||
|
startDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const STOP_DOWNLOADER = `
|
||||||
|
mutation StopDownloader {
|
||||||
|
stopDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CLEAR_DOWNLOADER = `
|
||||||
|
mutation ClearDownloader {
|
||||||
|
clearDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_SOURCE_MANGA = `
|
||||||
|
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 thumbnailUrl inLibrary }
|
||||||
|
hasNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
export const FETCH_EXTENSIONS = `
|
||||||
|
mutation FetchExtensions {
|
||||||
|
fetchExtensions(input: {}) {
|
||||||
|
extensions {
|
||||||
|
apkName pkgName name lang versionName
|
||||||
|
isInstalled isObsolete hasUpdate iconUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_EXTENSION = `
|
||||||
|
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||||
|
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||||
|
extension { apkName pkgName name isInstalled hasUpdate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const INSTALL_EXTERNAL_EXTENSION = `
|
||||||
|
mutation InstallExternalExtension($url: String!) {
|
||||||
|
installExternalExtension(input: { extensionUrl: $url }) {
|
||||||
|
extension { apkName pkgName name isInstalled }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_EXTENSION_REPOS = `
|
||||||
|
mutation SetExtensionRepos($repos: [String!]!) {
|
||||||
|
setSettings(input: { settings: { extensionRepos: $repos } }) {
|
||||||
|
settings { extensionRepos }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./manga";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./downloads";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./tracking";
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
export const FETCH_MANGA = `
|
||||||
|
mutation FetchManga($id: Int!) {
|
||||||
|
fetchManga(input: { id: $id }) {
|
||||||
|
manga {
|
||||||
|
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||||
|
source { id name displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGA = `
|
||||||
|
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
|
||||||
|
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
|
||||||
|
manga { id inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGAS = `
|
||||||
|
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
|
||||||
|
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
|
||||||
|
mangas { id inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGA_CATEGORIES = `
|
||||||
|
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||||
|
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||||
|
manga { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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_LIBRARY = `
|
||||||
|
mutation UpdateLibrary {
|
||||||
|
updateLibrary(input: {}) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CREATE_BACKUP = `
|
||||||
|
mutation CreateBackup {
|
||||||
|
createBackup(input: {}) { url }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RESTORE_BACKUP = `
|
||||||
|
mutation RestoreBackup($backup: Upload!) {
|
||||||
|
restoreBackup(input: { backup: $backup }) {
|
||||||
|
id
|
||||||
|
status { mangaProgress state totalManga }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
# Mutations
|
||||||
|
|
||||||
|
## Manga (`mutations/manga.ts`)
|
||||||
|
|
||||||
|
### `FETCH_MANGA`
|
||||||
|
Fetches and refreshes manga metadata from its source.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Manga ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_MANGA`
|
||||||
|
Updates a single manga's library membership.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Manga ID |
|
||||||
|
| `inLibrary` | `Boolean` | Add/remove from library |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_MANGAS`
|
||||||
|
Bulk-updates library membership for multiple manga.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ids` | `[Int!]!` | Manga IDs |
|
||||||
|
| `inLibrary` | `Boolean` | Add/remove from library |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_MANGA_CATEGORIES`
|
||||||
|
Adds or removes a manga from categories.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `mangaId` | `Int!` | Manga ID |
|
||||||
|
| `addTo` | `[Int!]!` | Category IDs to add to |
|
||||||
|
| `removeFrom` | `[Int!]!` | Category IDs to remove from |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `CREATE_CATEGORY`
|
||||||
|
Creates a new manga category.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `name` | `String!` | Category name |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_CATEGORY`
|
||||||
|
Updates a category's name.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Category ID |
|
||||||
|
| `name` | `String` | New name |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DELETE_CATEGORY`
|
||||||
|
Deletes a category by ID.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Category ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_CATEGORY_ORDER`
|
||||||
|
Moves a category to a new position.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Category ID |
|
||||||
|
| `position` | `Int!` | New position index |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_LIBRARY`
|
||||||
|
Triggers a library-wide metadata refresh and returns job status.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `CREATE_BACKUP`
|
||||||
|
Creates a backup and returns its download URL.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `RESTORE_BACKUP`
|
||||||
|
Restores a backup from an uploaded file and returns restore job status.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `backup` | `Upload!` | Backup file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chapters (`mutations/chapters.ts`)
|
||||||
|
|
||||||
|
### `FETCH_CHAPTERS`
|
||||||
|
Fetches/refreshes the chapter list for a manga from its source.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `mangaId` | `Int!` | Manga ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `FETCH_CHAPTER_PAGES`
|
||||||
|
Fetches the page URLs for a specific chapter.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `chapterId` | `Int!` | Chapter ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `MARK_CHAPTER_READ`
|
||||||
|
Marks a single chapter as read or unread.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Chapter ID |
|
||||||
|
| `isRead` | `Boolean!` | Read state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `MARK_CHAPTERS_READ`
|
||||||
|
Bulk-marks multiple chapters as read or unread.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ids` | `[Int!]!` | Chapter IDs |
|
||||||
|
| `isRead` | `Boolean!` | Read state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_CHAPTERS_PROGRESS`
|
||||||
|
Bulk-updates read state, bookmark state, and last page read for multiple chapters.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ids` | `[Int!]!` | Chapter IDs |
|
||||||
|
| `isRead` | `Boolean` | Read state |
|
||||||
|
| `isBookmarked` | `Boolean` | Bookmark state |
|
||||||
|
| `lastPageRead` | `Int` | Last page index read |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DELETE_DOWNLOADED_CHAPTERS`
|
||||||
|
Deletes downloaded chapter files for the given chapter IDs.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ids` | `[Int!]!` | Chapter IDs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads (`mutations/downloads.ts`)
|
||||||
|
|
||||||
|
### `ENQUEUE_DOWNLOAD`
|
||||||
|
Adds a single chapter to the download queue.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `chapterId` | `Int!` | Chapter ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `ENQUEUE_CHAPTERS_DOWNLOAD`
|
||||||
|
Adds multiple chapters to the download queue.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `chapterIds` | `[Int!]!` | Chapter IDs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DEQUEUE_DOWNLOAD`
|
||||||
|
Removes a chapter from the download queue.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `chapterId` | `Int!` | Chapter ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `START_DOWNLOADER`
|
||||||
|
Starts the downloader and returns the current queue state.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `STOP_DOWNLOADER`
|
||||||
|
Stops the downloader and returns the current queue state.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `CLEAR_DOWNLOADER`
|
||||||
|
Clears all items from the download queue.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `FETCH_SOURCE_MANGA`
|
||||||
|
Fetches manga from a source (browse/search), with pagination and optional filters.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `source` | `LongString!` | Source ID |
|
||||||
|
| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) |
|
||||||
|
| `page` | `Int!` | Page number |
|
||||||
|
| `query` | `String` | Search query |
|
||||||
|
| `filters` | `[FilterChangeInput!]` | Source-specific filters |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_DOWNLOADS_PATH`
|
||||||
|
Sets the downloads directory path in settings.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `path` | `String!` | Filesystem path |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_LOCAL_SOURCE_PATH`
|
||||||
|
Sets the local source directory path in settings.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `path` | `String!` | Filesystem path |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensions (`mutations/extensions.ts`)
|
||||||
|
|
||||||
|
### `FETCH_EXTENSIONS`
|
||||||
|
Fetches the latest extension list from configured repos.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_EXTENSION`
|
||||||
|
Installs, uninstalls, or updates an extension.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `String!` | Extension package name |
|
||||||
|
| `install` | `Boolean` | Install the extension |
|
||||||
|
| `uninstall` | `Boolean` | Uninstall the extension |
|
||||||
|
| `update` | `Boolean` | Update the extension |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `INSTALL_EXTERNAL_EXTENSION`
|
||||||
|
Installs an extension from an external APK URL.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `url` | `String!` | APK download URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_EXTENSION_REPOS`
|
||||||
|
Sets the list of extension repository URLs.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `repos` | `[String!]!` | Repository URLs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_SERVER_AUTH`
|
||||||
|
Configures server authentication mode and credentials.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `authMode` | `AuthMode!` | Auth mode |
|
||||||
|
| `authUsername` | `String!` | Username |
|
||||||
|
| `authPassword` | `String!` | Password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_SOCKS_PROXY`
|
||||||
|
Configures SOCKS proxy settings.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy |
|
||||||
|
| `socksProxyHost` | `String!` | Proxy host |
|
||||||
|
| `socksProxyPort` | `String!` | Proxy port |
|
||||||
|
| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) |
|
||||||
|
| `socksProxyUsername` | `String!` | Proxy username |
|
||||||
|
| `socksProxyPassword` | `String!` | Proxy password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_FLARESOLVERR`
|
||||||
|
Configures FlareSolverr integration settings.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr |
|
||||||
|
| `flareSolverrUrl` | `String!` | FlareSolverr URL |
|
||||||
|
| `flareSolverrTimeout` | `Int!` | Request timeout (ms) |
|
||||||
|
| `flareSolverrSessionName` | `String!` | Session name |
|
||||||
|
| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) |
|
||||||
|
| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracking (`mutations/tracking.ts`)
|
||||||
|
|
||||||
|
### `BIND_TRACK`
|
||||||
|
Binds a manga to a remote tracker entry.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `mangaId` | `Int!` | Manga ID |
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
|
| `remoteId` | `LongString!` | Remote entry ID on the tracker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_TRACK`
|
||||||
|
Updates tracking progress, status, score, and dates for a track record.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `recordId` | `Int!` | Track record ID |
|
||||||
|
| `status` | `Int` | Reading status |
|
||||||
|
| `lastChapterRead` | `Float` | Last chapter read |
|
||||||
|
| `scoreString` | `String` | Score in tracker's format |
|
||||||
|
| `startDate` | `LongString` | Start date |
|
||||||
|
| `finishDate` | `LongString` | Finish date |
|
||||||
|
| `private` | `Boolean` | Mark as private |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UNBIND_TRACK`
|
||||||
|
Unbinds a manga from a tracker record.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `recordId` | `Int!` | Track record ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `FETCH_TRACK`
|
||||||
|
Refreshes a track record from the remote tracker.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `recordId` | `Int!` | Track record ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LOGIN_TRACKER_OAUTH`
|
||||||
|
Initiates OAuth login for a tracker using a callback URL.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
|
| `callbackUrl` | `String!` | OAuth callback URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LOGIN_TRACKER_CREDENTIALS`
|
||||||
|
Logs into a tracker using username and password.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
|
| `username` | `String!` | Username |
|
||||||
|
| `password` | `String!` | Password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LOGOUT_TRACKER`
|
||||||
|
Logs out of a tracker.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LOGIN_USER`
|
||||||
|
Authenticates a user and returns access and refresh tokens.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `username` | `String!` | Username |
|
||||||
|
| `password` | `String!` | Password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `REFRESH_TOKEN`
|
||||||
|
Refreshes the current access token.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const TRACK_RECORD_FRAGMENT = `
|
||||||
|
id trackerId remoteId title status score displayScore
|
||||||
|
lastChapterRead totalChapters remoteUrl startDate finishDate private
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BIND_TRACK = `
|
||||||
|
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||||
|
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||||
|
trackRecord { ${TRACK_RECORD_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 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 }
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const GET_RECENTLY_UPDATED = `
|
||||||
|
query GetRecentlyUpdated {
|
||||||
|
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||||
|
nodes {
|
||||||
|
mangaId
|
||||||
|
fetchedAt
|
||||||
|
manga { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_CHAPTERS = `
|
||||||
|
query GetChapters($mangaId: Int!) {
|
||||||
|
chapters(condition: { mangaId: $mangaId }) {
|
||||||
|
nodes {
|
||||||
|
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||||
|
pageCount mangaId uploadDate realUrl lastPageRead scanlator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const GET_DOWNLOAD_STATUS = `
|
||||||
|
query GetDownloadStatus {
|
||||||
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress state
|
||||||
|
chapter {
|
||||||
|
id name pageCount mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export const GET_LOCAL_MANGA = `
|
||||||
|
query GetLocalManga {
|
||||||
|
mangas(condition: { sourceId: "0" }) {
|
||||||
|
nodes { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_EXTENSIONS = `
|
||||||
|
query GetExtensions {
|
||||||
|
extensions {
|
||||||
|
nodes {
|
||||||
|
apkName pkgName name lang versionName
|
||||||
|
isInstalled isObsolete hasUpdate iconUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SOURCES = `
|
||||||
|
query GetSources {
|
||||||
|
sources {
|
||||||
|
nodes { id name lang displayName iconUrl isNsfw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SETTINGS = `
|
||||||
|
query GetSettings {
|
||||||
|
settings { extensionRepos }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SERVER_SECURITY = `
|
||||||
|
query GetServerSecurity {
|
||||||
|
settings {
|
||||||
|
authMode authUsername
|
||||||
|
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
|
||||||
|
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
||||||
|
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./manga";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./downloads";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./tracking";
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
export const GET_LIBRARY = `
|
||||||
|
query GetLibrary {
|
||||||
|
mangas(condition: { inLibrary: true }) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary downloadCount unreadCount
|
||||||
|
description status author artist genre
|
||||||
|
source { id name displayName }
|
||||||
|
chapters { totalCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ALL_MANGA = `
|
||||||
|
query GetAllManga {
|
||||||
|
mangas {
|
||||||
|
nodes { id title thumbnailUrl inLibrary downloadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MANGA = `
|
||||||
|
query GetManga($id: Int!) {
|
||||||
|
manga(id: $id) {
|
||||||
|
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||||
|
source { id name displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_CATEGORIES = `
|
||||||
|
query GetCategories {
|
||||||
|
categories {
|
||||||
|
nodes {
|
||||||
|
id name order default includeInUpdate includeInDownload
|
||||||
|
mangas {
|
||||||
|
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
|
||||||
|
query GetDownloadedChaptersPages {
|
||||||
|
chapters(condition: { isDownloaded: true }) {
|
||||||
|
nodes { pageCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_DOWNLOADS_PATH = `
|
||||||
|
query GetDownloadsPath {
|
||||||
|
settings { downloadsPath localSourcePath }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LIBRARY_UPDATE_STATUS = `
|
||||||
|
query LibraryUpdateStatus {
|
||||||
|
libraryUpdateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs skippedMangasCount }
|
||||||
|
mangaUpdates {
|
||||||
|
status
|
||||||
|
manga { id title thumbnailUrl unreadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_RESTORE_STATUS = `
|
||||||
|
query GetRestoreStatus($id: String!) {
|
||||||
|
restoreStatus(id: $id) { mangaProgress state totalManga }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VALIDATE_BACKUP = `
|
||||||
|
query ValidateBackup($backup: Upload!) {
|
||||||
|
validateBackup(input: { backup: $backup }) {
|
||||||
|
missingSources { id name }
|
||||||
|
missingTrackers { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MANGAS_BY_GENRE = `
|
||||||
|
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
||||||
|
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary genre status
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
pageInfo { hasNextPage }
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Queries
|
||||||
|
|
||||||
|
## Manga (`queries/manga.ts`)
|
||||||
|
|
||||||
|
### `GET_LIBRARY`
|
||||||
|
Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_ALL_MANGA`
|
||||||
|
Fetches all manga (library and non-library) with minimal fields.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_MANGA`
|
||||||
|
Fetches a single manga by ID with full metadata and source info.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Manga ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_CATEGORIES`
|
||||||
|
Fetches all categories with their order, settings, and the manga assigned to each.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_DOWNLOADED_CHAPTERS_PAGES`
|
||||||
|
Fetches page counts for all downloaded chapters.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_DOWNLOADS_PATH`
|
||||||
|
Fetches the configured downloads path and local source path from settings.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LIBRARY_UPDATE_STATUS`
|
||||||
|
Fetches the current library update job status, including progress and any manga with new chapters.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_RESTORE_STATUS`
|
||||||
|
Fetches the status of a backup restore operation by its job ID.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `String!` | Restore job ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `VALIDATE_BACKUP`
|
||||||
|
Validates a backup file and returns any missing sources or trackers.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `backup` | `Upload!` | Backup file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chapters (`queries/chapters.ts`)
|
||||||
|
|
||||||
|
### `GET_CHAPTERS`
|
||||||
|
Fetches all chapters for a given manga, including read/download/bookmark state and page info.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `mangaId` | `Int!` | Manga ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads (`queries/downloads.ts`)
|
||||||
|
|
||||||
|
### `GET_DOWNLOAD_STATUS`
|
||||||
|
Fetches the current downloader state and full queue with chapter and manga info.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensions (`queries/extensions.ts`)
|
||||||
|
|
||||||
|
### `GET_EXTENSIONS`
|
||||||
|
Fetches all extensions with install status, update availability, and metadata.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_SOURCES`
|
||||||
|
Fetches all available sources with language and NSFW flags.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_SETTINGS`
|
||||||
|
Fetches extension repository settings.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_SERVER_SECURITY`
|
||||||
|
Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracking (`queries/tracking.ts`)
|
||||||
|
|
||||||
|
### `GET_TRACKERS`
|
||||||
|
Fetches all trackers with login status, supported scores, statuses, and auth info.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_MANGA_TRACK_RECORDS`
|
||||||
|
Fetches all tracking records for a specific manga across all trackers.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `mangaId` | `Int!` | Manga ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SEARCH_TRACKER`
|
||||||
|
Searches a tracker for manga by query string.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
|
| `query` | `String!` | Search query |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_ALL_TRACKER_RECORDS`
|
||||||
|
Fetches all trackers and their full track records, including associated manga info.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_TRACKER_RECORDS`
|
||||||
|
Fetches track records for a specific tracker.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { store } from "../../store/state.svelte";
|
|
||||||
import Sidebar from "./Sidebar.svelte";
|
|
||||||
import Home from "../pages/Home.svelte";
|
|
||||||
import Library from "../pages/Library.svelte";
|
|
||||||
import SeriesDetail from "../series/SeriesDetail.svelte";
|
|
||||||
import RecentActivity from "./RecentActivity.svelte";
|
|
||||||
import Search from "../pages/Search.svelte";
|
|
||||||
import Discover from "../pages/Discover.svelte";
|
|
||||||
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
|
|
||||||
import Downloads from "../pages/Downloads.svelte";
|
|
||||||
import Extensions from "../pages/Extensions.svelte";
|
|
||||||
import Tracking from "../pages/Tracking.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<Sidebar />
|
|
||||||
<main class="main">
|
|
||||||
{#if store.activeManga}
|
|
||||||
<SeriesDetail />
|
|
||||||
{:else if store.navPage === "home"}
|
|
||||||
<Home />
|
|
||||||
{:else if store.navPage === "library"}
|
|
||||||
<Library />
|
|
||||||
{:else if store.navPage === "search"}
|
|
||||||
<Search />
|
|
||||||
{:else if store.navPage === "history"}
|
|
||||||
<RecentActivity />
|
|
||||||
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
|
|
||||||
<GenreDrillPage />
|
|
||||||
{:else if store.navPage === "explore" || store.navPage === "sources"}
|
|
||||||
<Discover />
|
|
||||||
{:else if store.navPage === "downloads"}
|
|
||||||
<Downloads />
|
|
||||||
{:else if store.navPage === "extensions"}
|
|
||||||
<Extensions />
|
|
||||||
{:else if store.navPage === "tracking"}
|
|
||||||
<Tracking />
|
|
||||||
{:else}
|
|
||||||
<Home />
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; height: 100%; background: var(--bg-base); overflow: hidden; }
|
|
||||||
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; }
|
|
||||||
</style>
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
|
||||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
|
||||||
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
|
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
let search = $state("");
|
|
||||||
let confirmClear = $state(false);
|
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "Just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayLabel(ts: number): string {
|
|
||||||
const d = new Date(ts), now = new Date();
|
|
||||||
if (d.toDateString() === now.toDateString()) return "Today";
|
|
||||||
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
|
||||||
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
|
||||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReadTime(m: number): string {
|
|
||||||
if (m < 1) return "< 1 min";
|
|
||||||
if (m < 60) return `${m} min`;
|
|
||||||
const h = Math.floor(m / 60), r = m % 60;
|
|
||||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
mangaId: number;
|
|
||||||
mangaTitle: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
latestChapterId: number;
|
|
||||||
latestChapterName: string;
|
|
||||||
latestPageNumber: number;
|
|
||||||
firstChapterName: string;
|
|
||||||
chapterCount: number;
|
|
||||||
readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessions(entries: HistoryEntry[]): Session[] {
|
|
||||||
if (!entries.length) return [];
|
|
||||||
const sessions: Session[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < entries.length) {
|
|
||||||
const anchor = entries[i];
|
|
||||||
const group: HistoryEntry[] = [anchor];
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < entries.length) {
|
|
||||||
const next = entries[j];
|
|
||||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
|
||||||
group.push(next); j++;
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
const latest = group[0], oldest = group[group.length - 1];
|
|
||||||
sessions.push({
|
|
||||||
mangaId: latest.mangaId,
|
|
||||||
mangaTitle: latest.mangaTitle,
|
|
||||||
thumbnailUrl: latest.thumbnailUrl,
|
|
||||||
latestChapterId: latest.chapterId,
|
|
||||||
latestChapterName: latest.chapterName,
|
|
||||||
latestPageNumber: latest.pageNumber,
|
|
||||||
firstChapterName: oldest.chapterName,
|
|
||||||
chapterCount: group.length,
|
|
||||||
readAt: latest.readAt,
|
|
||||||
});
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
return sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = $derived(search.trim()
|
|
||||||
? store.history.filter((e) =>
|
|
||||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
e.chapterName.toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
: store.history);
|
|
||||||
|
|
||||||
const sessions = $derived(buildSessions(filtered));
|
|
||||||
|
|
||||||
const groups = $derived.by(() => {
|
|
||||||
const map = new Map<string, Session[]>();
|
|
||||||
for (const s of sessions) {
|
|
||||||
const l = dayLabel(s.readAt);
|
|
||||||
if (!map.has(l)) map.set(l, []);
|
|
||||||
map.get(l)!.push(s);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resume: navigate to the manga's SeriesDetail (which will pick up from
|
|
||||||
// activeChapterList once chapters load). We can't hold a stale chapter list
|
|
||||||
// here — SeriesDetail fetches fresh chapters itself.
|
|
||||||
function resume(session: Session) {
|
|
||||||
setActiveManga({
|
|
||||||
id: session.mangaId,
|
|
||||||
title: session.mangaTitle,
|
|
||||||
thumbnailUrl: session.thumbnailUrl,
|
|
||||||
inLibrary: false,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
if (!confirmClear) { confirmClear = true; setTimeout(() => confirmClear = false, 3000); return; }
|
|
||||||
clearHistory(); confirmClear = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<span class="heading">History</span>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search history…" bind:value={search} />
|
|
||||||
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.history.length > 0}
|
|
||||||
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
|
|
||||||
title={confirmClear ? "Click again to confirm" : "Clear history"}>
|
|
||||||
<Trash size={14} weight="light" />
|
|
||||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if store.readingStats.totalChaptersRead > 0}
|
|
||||||
<div class="stats-bar">
|
|
||||||
<div class="stat-group">
|
|
||||||
<Fire size={13} weight="fill" class="stat-fire" />
|
|
||||||
<span class="stat-val accent">{store.readingStats.currentStreakDays}</span>
|
|
||||||
<span class="stat-label">day streak</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
|
|
||||||
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
|
|
||||||
<span class="stat-label">chapters</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<Clock size={13} weight="light" class="stat-icon-neutral" />
|
|
||||||
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
|
|
||||||
<span class="stat-label">read time</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
|
|
||||||
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
|
|
||||||
<span class="stat-label">series</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<span class="stat-val muted">{store.readingStats.longestStreakDays}d</span>
|
|
||||||
<span class="stat-label">best streak</span>
|
|
||||||
</div>
|
|
||||||
<span class="stats-note">Stats are preserved when you clear the feed</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if store.history.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No reading history yet</p>
|
|
||||||
<p class="empty-hint">Chapters you read will appear here</p>
|
|
||||||
</div>
|
|
||||||
{:else if sessions.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<Books size={28} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No results for "{search}"</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="timeline">
|
|
||||||
{#each groups as { label, items }}
|
|
||||||
<div class="day-group">
|
|
||||||
<div class="day-label-row">
|
|
||||||
<span class="day-label">{label}</span>
|
|
||||||
<div class="day-line"></div>
|
|
||||||
</div>
|
|
||||||
<div class="session-list">
|
|
||||||
{#each items as session (session.latestChapterId)}
|
|
||||||
<button class="session-row" onclick={() => resume(session)}>
|
|
||||||
<div class="thumb-wrap">
|
|
||||||
<Thumbnail src={session.thumbnailUrl} alt={session.mangaTitle} class="thumb" />
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
<span class="session-count">{session.chapterCount}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="session-info">
|
|
||||||
<span class="session-title">{session.mangaTitle}</span>
|
|
||||||
<span class="session-chapter">
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
{session.firstChapterName}
|
|
||||||
<span class="ch-arrow">→</span>
|
|
||||||
{session.latestChapterName}
|
|
||||||
{:else}
|
|
||||||
{session.latestChapterName}
|
|
||||||
{#if session.latestPageNumber > 1}
|
|
||||||
<span class="ch-page">p.{session.latestPageNumber}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="session-time">{timeAgo(session.readAt)}</span>
|
|
||||||
<div class="play-pill">
|
|
||||||
<Play size={10} weight="fill" /> Resume
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.search-clear { position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
|
||||||
.search-clear:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
height: 28px; padding: 0 var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint); background: none; border: 1px solid transparent;
|
|
||||||
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
|
||||||
.clear-label { font-size: var(--text-2xs); }
|
|
||||||
|
|
||||||
.stats-bar {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
|
|
||||||
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.stat-group { display: flex; align-items: center; gap: 5px; }
|
|
||||||
.stat-sep { width: 1px; height: 14px; background: var(--border-dim); flex-shrink: 0; }
|
|
||||||
:global(.stat-fire) { color: #f97316; }
|
|
||||||
:global(.stat-icon-neutral) { color: var(--text-faint); }
|
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.stat-val.accent { color: var(--accent-fg); }
|
|
||||||
.stat-val.muted { color: var(--text-faint); }
|
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.stats-note { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.5; letter-spacing: var(--tracking-wide); font-style: italic; }
|
|
||||||
|
|
||||||
.timeline { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
|
||||||
|
|
||||||
.day-group { margin-bottom: var(--sp-5); }
|
|
||||||
.day-label-row { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); }
|
|
||||||
.day-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; white-space: nowrap; flex-shrink: 0; }
|
|
||||||
.day-line { flex: 1; height: 1px; background: var(--border-dim); }
|
|
||||||
|
|
||||||
.session-list { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
|
|
||||||
.session-row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.session-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
|
|
||||||
|
|
||||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
|
||||||
:global(.thumb) { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
|
||||||
.session-count {
|
|
||||||
position: absolute; bottom: -4px; right: -6px;
|
|
||||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
|
||||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
|
||||||
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.session-chapter { font-size: var(--text-xs); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.ch-arrow { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
|
||||||
.ch-page { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.session-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
|
||||||
.play-pill {
|
|
||||||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
|
||||||
padding: 3px 8px; border-radius: var(--radius-full);
|
|
||||||
opacity: 0; transform: translateX(4px);
|
|
||||||
transition: opacity var(--t-base), transform var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
|
||||||
:global(.empty-icon) { color: var(--text-faint); }
|
|
||||||
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
|
|
||||||
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
|
||||||
import type { NavPage } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
const TABS: { id: NavPage; label: string; icon: any }[] = [
|
|
||||||
{ id: "home", label: "Home", icon: House },
|
|
||||||
{ id: "library", label: "Library", icon: Books },
|
|
||||||
{ id: "search", label: "Search", icon: MagnifyingGlass },
|
|
||||||
{ id: "history", label: "History", icon: ClockCounterClockwise },
|
|
||||||
{ id: "explore", label: "Discover", icon: Compass },
|
|
||||||
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
|
||||||
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
|
||||||
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
|
|
||||||
];
|
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
|
||||||
store.navPage = id;
|
|
||||||
store.activeManga = null;
|
|
||||||
store.genreFilter = "";
|
|
||||||
if (id !== "explore") store.activeSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goHome() {
|
|
||||||
store.navPage = "home";
|
|
||||||
store.activeSource = null;
|
|
||||||
store.activeManga = null;
|
|
||||||
store.libraryFilter = "library";
|
|
||||||
store.genreFilter = "";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside class="root">
|
|
||||||
<button class="logo" onclick={goHome} title="Home" aria-label="Go to Home">
|
|
||||||
<div class="logo-icon"></div>
|
|
||||||
</button>
|
|
||||||
<nav class="nav">
|
|
||||||
{#each TABS as tab}
|
|
||||||
<button class="tab" class:active={store.navPage === tab.id}
|
|
||||||
title={tab.label} onclick={() => navigate(tab.id)}>
|
|
||||||
<tab.icon size={18} weight="light" />
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
<div class="bottom">
|
|
||||||
<button class="settings-btn" onclick={() => store.settingsOpen = true} title="Settings">
|
|
||||||
<GearSix size={18} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; }
|
|
||||||
.logo { width: 80px; height: 80px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
|
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
|
||||||
.logo:active { transform: scale(0.92); }
|
|
||||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
|
|
||||||
.nav { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
|
|
||||||
.nav::-webkit-scrollbar { display: none; }
|
|
||||||
.tab { width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
||||||
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.bottom { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
|
||||||
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
|
||||||
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
|
||||||
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
||||||
</style>
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { store, dismissToast } from "../../store/state.svelte";
|
|
||||||
import type { Toast } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
const EXIT_MS = 280;
|
|
||||||
const leaving = new Set<string>();
|
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
function schedule(t: Toast) {
|
|
||||||
if (timers.has(t.id)) return;
|
|
||||||
const dur = t.duration ?? 3500;
|
|
||||||
if (dur === 0) return;
|
|
||||||
timers.set(t.id, setTimeout(() => dismiss(t.id), dur));
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismiss(id: string) {
|
|
||||||
if (leaving.has(id)) return;
|
|
||||||
leaving.add(id);
|
|
||||||
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id); }
|
|
||||||
|
|
||||||
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`);
|
|
||||||
if (!el) { finalize(id); return; }
|
|
||||||
|
|
||||||
const h = el.offsetHeight;
|
|
||||||
el.style.setProperty("--exit-h", `${h}px`);
|
|
||||||
el.classList.add("leaving");
|
|
||||||
setTimeout(() => finalize(id), EXIT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function finalize(id: string) {
|
|
||||||
leaving.delete(id);
|
|
||||||
dismissToast(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
store.toasts.forEach(schedule);
|
|
||||||
return () => timers.forEach(clearTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
const icons: Record<Toast["kind"], string> = {
|
|
||||||
success: "M20 6L9 17l-5-5",
|
|
||||||
error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
|
|
||||||
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.toasts.length}
|
|
||||||
<div class="toaster" aria-live="polite">
|
|
||||||
{#each store.toasts as t (t.id)}
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
class="toast toast-{t.kind}"
|
|
||||||
data-toast-id={t.id}
|
|
||||||
onclick={() => dismiss(t.id)}
|
|
||||||
>
|
|
||||||
<div class="accent-bar"></div>
|
|
||||||
<span class="icon">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d={icons[t.kind]} />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div class="body">
|
|
||||||
<p class="title">{t.title}</p>
|
|
||||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toaster {
|
|
||||||
position: fixed;
|
|
||||||
bottom: var(--sp-5);
|
|
||||||
right: var(--sp-5);
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
pointer-events: none;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
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);
|
|
||||||
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;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
color: inherit;
|
|
||||||
text-align: left;
|
|
||||||
will-change: transform, opacity;
|
|
||||||
animation: slideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast:hover {
|
|
||||||
border-color: var(--border-base);
|
|
||||||
box-shadow: 0 12px 40px rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.06) inset;
|
|
||||||
transform: translateX(-3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast:active { transform: translateX(0) scale(0.98); }
|
|
||||||
|
|
||||||
:global(.toast.leaving) {
|
|
||||||
animation: slideOut 0.28s cubic-bezier(0.4, 0, 1, 1) forwards !important;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from { opacity: 0; transform: translateX(20px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideOut {
|
|
||||||
0% { opacity: 1; transform: translateX(0) scale(1); max-height: var(--exit-h, 80px); margin-bottom: 0; }
|
|
||||||
40% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: var(--exit-h, 80px); margin-bottom: 0; }
|
|
||||||
100% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: 0; margin-bottom: -6px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 .icon { color: var(--color-error); }
|
|
||||||
.toast-info .icon { color: var(--text-muted); }
|
|
||||||
.toast-download .icon { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 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-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy } from "svelte";
|
|
||||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
|
||||||
import { gql } from "../../lib/client";
|
|
||||||
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, 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";
|
|
||||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
|
||||||
|
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
|
||||||
const GRID_LIMIT = 200;
|
|
||||||
const CONCURRENCY = 6;
|
|
||||||
const PAGES_INIT = 3;
|
|
||||||
const PAGES_GENRE = 2;
|
|
||||||
|
|
||||||
const EXPLORE_ALL_MANGA = `
|
|
||||||
query ExploreAllManga {
|
|
||||||
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const MANGAS_BY_GENRE = `
|
|
||||||
query MangasByGenre($genre: String!, $first: Int) {
|
|
||||||
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function dKey(srcId: string, type: string, genre: string, page: number) {
|
|
||||||
return `${srcId}|${type}|${genre}:p${page}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let allSources: Source[] = $state([]);
|
|
||||||
let loadingLib = $state(true);
|
|
||||||
let loadError = $state(false);
|
|
||||||
let currentGenre = $state("All");
|
|
||||||
let genreResults = $state(new Map<string, Manga[]>());
|
|
||||||
let genreLoading = $state(false);
|
|
||||||
let refreshing = $state(false);
|
|
||||||
|
|
||||||
let activeCtrl: AbortController | null = null;
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
|
||||||
let categories: Category[] = $state([]);
|
|
||||||
let catsLoaded = false;
|
|
||||||
|
|
||||||
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
|
||||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
|
||||||
|
|
||||||
function dedup(items: Manga[]): Manga[] {
|
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 () => {
|
|
||||||
while (i < items.length) {
|
|
||||||
if (signal.aborted) return;
|
|
||||||
await fn(items[i++]).catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fanOut(genre: string, ctrl: AbortController) {
|
|
||||||
const srcs = rotatedSources();
|
|
||||||
if (!srcs.length) return;
|
|
||||||
|
|
||||||
const isAll = genre === "All";
|
|
||||||
const type = isAll ? "POPULAR" : "SEARCH";
|
|
||||||
const query = isAll ? null : genre;
|
|
||||||
const maxPages = isAll ? PAGES_INIT : PAGES_GENRE;
|
|
||||||
|
|
||||||
await runConcurrent(srcs, async src => {
|
|
||||||
for (let page = 1; page <= maxPages; page++) {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const key = dKey(src.id, type, genre, page);
|
|
||||||
let mangas: Manga[];
|
|
||||||
let hasNextPage = false;
|
|
||||||
|
|
||||||
if (store.discoverCache.has(key)) {
|
|
||||||
mangas = store.discoverCache.get(key)!;
|
|
||||||
} else {
|
|
||||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
|
||||||
FETCH_SOURCE_MANGA,
|
|
||||||
{ source: src.id, type, page, query },
|
|
||||||
ctrl.signal
|
|
||||||
).then(d => d.fetchSourceManga).catch(() => null);
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
|
||||||
mangas = result.mangas;
|
|
||||||
hasNextPage = result.hasNextPage;
|
|
||||||
store.discoverCache.set(key, mangas);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
if (isAll) {
|
|
||||||
pushToGrid("All", mangas);
|
|
||||||
} else {
|
|
||||||
const matching = mangas.filter(m =>
|
|
||||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
|
||||||
);
|
|
||||||
pushToGrid(genre, matching.length ? matching : mangas);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasNextPage) return;
|
|
||||||
}
|
|
||||||
}, ctrl.signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchGenre(genre: string) {
|
|
||||||
if (currentGenre === genre) return;
|
|
||||||
|
|
||||||
activeCtrl?.abort();
|
|
||||||
currentGenre = genre;
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
activeCtrl = ctrl;
|
|
||||||
|
|
||||||
if (genre === "All") {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localKey = `local|${genre}`;
|
|
||||||
if (store.discoverCache.has(localKey)) {
|
|
||||||
genreResults.set(genre, store.discoverCache.get(localKey)!);
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
fanOut(genre, ctrl).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
genreLoading = true;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ mangas: { nodes: Manga[] } }>(
|
|
||||||
MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal
|
|
||||||
);
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const local = dedup(d.mangas.nodes.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;
|
|
||||||
|
|
||||||
fanOut(genre, ctrl).catch(() => {});
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
activeCtrl?.abort();
|
|
||||||
clearDiscoverCache();
|
|
||||||
genreResults = new Map();
|
|
||||||
refreshing = true;
|
|
||||||
genreLoading = true;
|
|
||||||
const genre = currentGenre;
|
|
||||||
currentGenre = "";
|
|
||||||
await new Promise(r => setTimeout(r, 20));
|
|
||||||
await switchGenre(genre);
|
|
||||||
refreshing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAll() {
|
|
||||||
loadingLib = true;
|
|
||||||
loadError = false;
|
|
||||||
|
|
||||||
if ((genreResults.get("All") ?? []).length > 0) {
|
|
||||||
loadingLib = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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; });
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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);
|
|
||||||
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
|
||||||
}).catch(console.error),
|
|
||||||
},
|
|
||||||
...(categories.length > 0 ? [
|
|
||||||
{ separator: true } as MenuEntry,
|
|
||||||
...categories.map(cat => ({
|
|
||||||
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
|
||||||
icon: Folder,
|
|
||||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
|
||||||
})),
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: "New folder & add", icon: FolderSimplePlus,
|
|
||||||
onClick: async () => {
|
|
||||||
const n = prompt("Folder name:");
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.activeSource}
|
|
||||||
<SourceBrowse />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<span class="heading">Discover</span>
|
|
||||||
<div class="tab-strip">
|
|
||||||
{#each GENRE_TABS as tab (tab)}
|
|
||||||
<button class="genre-tab" class:active={currentGenre === tab} onclick={() => switchGenre(tab)}>
|
|
||||||
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="refresh-btn" class:spinning={refreshing} onclick={refresh} title="Refresh results" disabled={refreshing}>
|
|
||||||
<ArrowsClockwise size={13} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="body">
|
|
||||||
{#if isLoading && visibleGrid.length === 0}
|
|
||||||
<div class="manga-grid">
|
|
||||||
{#each Array(24) as _, i (i)}
|
|
||||||
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if loadError && visibleGrid.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<span>Could not reach Suwayomi</span>
|
|
||||||
<button class="retry-btn" onclick={loadAll}>Retry</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if visibleGrid.length === 0}
|
|
||||||
<div class="empty"><span>Nothing found for "{currentGenre}"</span></div>
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
<div class="manga-grid">
|
|
||||||
{#each visibleGrid as m (m.id)}
|
|
||||||
<button class="manga-card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => openCtx(e, m)}>
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
|
||||||
<div class="cover-gradient"></div>
|
|
||||||
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
|
||||||
<div class="card-footer">
|
|
||||||
<p class="card-title">{m.title}</p>
|
|
||||||
{#if m.source?.displayName}<p class="card-source">{m.source.displayName}</p>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if ctx}
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.header { display: flex; align-items: center; gap: var(--sp-4); flex-shrink: 0; padding: var(--sp-3) var(--sp-4) var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); overflow-x: auto; scrollbar-width: none; }
|
|
||||||
.header::-webkit-scrollbar { display: none; }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
|
||||||
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.genre-tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
|
||||||
.genre-tab:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.genre-tab.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.refresh-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; margin-left: auto; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.refresh-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
|
||||||
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
|
|
||||||
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.manga-card:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
|
||||||
.manga-card:hover .card-title { color: #fff; }
|
|
||||||
.manga-card:hover { will-change: transform; }
|
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
|
|
||||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
|
||||||
.cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
|
||||||
.lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); }
|
|
||||||
.card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
|
||||||
.card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); transition: color var(--t-base); }
|
|
||||||
.card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.card-skeleton { padding: 0; }
|
|
||||||
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
.skeleton { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
|
|
||||||
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.85 } }
|
|
||||||
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); padding: var(--sp-10) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.retry-btn { padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.refresh-btn.spinning { opacity: 0.5; cursor: default; }
|
|
||||||
.refresh-btn.spinning :global(svg) { animation: spin 0.8s linear infinite; }
|
|
||||||
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
|
||||||
import { gql } from "../../lib/client";
|
|
||||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
|
||||||
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { store, setActiveDownloads } from "../../store/state.svelte";
|
|
||||||
import type { DownloadStatus } from "../../lib/types";
|
|
||||||
|
|
||||||
let status: DownloadStatus | null = $state(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
let togglingPlay = $state(false);
|
|
||||||
let clearing = $state(false);
|
|
||||||
let dequeueing = $state(new Set<number>());
|
|
||||||
let interval: ReturnType<typeof setInterval>;
|
|
||||||
|
|
||||||
function applyStatus(ds: DownloadStatus) {
|
|
||||||
status = ds;
|
|
||||||
setActiveDownloads(ds.queue.map((item) => ({
|
|
||||||
chapterId: item.chapter.id,
|
|
||||||
mangaId: item.chapter.mangaId,
|
|
||||||
progress: item.progress,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then((d) => applyStatus(d.downloadStatus))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => loading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { poll(); interval = setInterval(poll, 2000); return () => clearInterval(interval); });
|
|
||||||
|
|
||||||
async function togglePlay() {
|
|
||||||
if (togglingPlay) return;
|
|
||||||
togglingPlay = true;
|
|
||||||
const wasRunning = status?.state === "STARTED";
|
|
||||||
if (status) status = { ...status, state: wasRunning ? "STOPPED" : "STARTED" };
|
|
||||||
try {
|
|
||||||
if (wasRunning) {
|
|
||||||
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
|
||||||
applyStatus(d.stopDownloader.downloadStatus);
|
|
||||||
} else {
|
|
||||||
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
|
||||||
applyStatus(d.startDownloader.downloadStatus);
|
|
||||||
}
|
|
||||||
} catch (e) { console.error(e); poll(); }
|
|
||||||
finally { togglingPlay = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clear() {
|
|
||||||
if (clearing) return;
|
|
||||||
clearing = true;
|
|
||||||
if (status) status = { ...status, queue: [] };
|
|
||||||
setActiveDownloads([]);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
|
||||||
applyStatus(d.clearDownloader.downloadStatus);
|
|
||||||
} catch (e) { console.error(e); poll(); }
|
|
||||||
finally { clearing = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dequeue(chapterId: number) {
|
|
||||||
if (dequeueing.has(chapterId)) return;
|
|
||||||
dequeueing = new Set(dequeueing).add(chapterId);
|
|
||||||
if (status) status = { ...status, queue: status.queue.filter((i) => i.chapter.id !== chapterId) };
|
|
||||||
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); poll(); }
|
|
||||||
catch (e) { console.error(e); poll(); }
|
|
||||||
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
|
|
||||||
}
|
|
||||||
let queue = $derived(status?.queue ?? []);
|
|
||||||
const isRunning = $derived(status?.state === "STARTED");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="heading">Downloads</h1>
|
|
||||||
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
|
|
||||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
|
||||||
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
|
|
||||||
{:else if isRunning}<Pause size={14} weight="fill" />
|
|
||||||
{:else}<Play size={14} weight="fill" />{/if}
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" class:loading={clearing} onclick={clear}
|
|
||||||
disabled={clearing || queue.length === 0} title="Clear queue">
|
|
||||||
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
|
|
||||||
{:else}<Trash size={14} weight="regular" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<div class="status-bar">
|
|
||||||
<div class="status-dot" class:active={isRunning}></div>
|
|
||||||
<span class="status-text">
|
|
||||||
{togglingPlay ? (isRunning ? "Pausing…" : "Starting…") : isRunning ? "Downloading" : "Paused"}
|
|
||||||
</span>
|
|
||||||
<span class="status-count">{queue.length} queued</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{:else if queue.length === 0}
|
|
||||||
<div class="empty">Queue is empty.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="list">
|
|
||||||
{#each queue as item, i (item.chapter.id)}
|
|
||||||
{@const isActive = i === 0 && isRunning}
|
|
||||||
{@const pages = item.chapter.pageCount ?? 0}
|
|
||||||
{@const done = Math.round(item.progress * pages)}
|
|
||||||
{@const manga = item.chapter.manga}
|
|
||||||
{@const isRemoving = dequeueing.has(item.chapter.id)}
|
|
||||||
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
|
|
||||||
{#if manga?.thumbnailUrl}
|
|
||||||
<div class="thumb">
|
|
||||||
<Thumbnail src={manga.thumbnailUrl} alt={manga?.title} class="thumb-img" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="info">
|
|
||||||
{#if manga?.title}<span class="manga-title">{manga.title}</span>{/if}
|
|
||||||
<span class="chapter-name">{item.chapter.name}</span>
|
|
||||||
{#if pages > 0}
|
|
||||||
<span class="pages-label">{isActive ? `${done} / ${pages} pages` : `${pages} pages`}</span>
|
|
||||||
{/if}
|
|
||||||
{#if isActive}
|
|
||||||
<div class="progress-wrap">
|
|
||||||
<div class="progress-bar" style="width:{Math.round(item.progress * 100)}%"></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<span class="state-label">{item.state}</span>
|
|
||||||
{#if !isActive}
|
|
||||||
<button class="remove-btn" onclick={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
|
|
||||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div><!-- .content -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.header-actions { display: flex; gap: var(--sp-2); }
|
|
||||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
|
|
||||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
|
||||||
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
|
|
||||||
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
|
||||||
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); transition: border-color var(--t-fast), opacity var(--t-base); }
|
|
||||||
.row.row-active { border-color: var(--accent-dim); }
|
|
||||||
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
|
||||||
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
|
||||||
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-wrap { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; margin-top: 4px; }
|
|
||||||
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
|
||||||
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.remove-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.remove-btn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.remove-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
</style>
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { untrack } from "svelte";
|
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
|
||||||
import { gql } from "../../lib/client";
|
|
||||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
|
||||||
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
|
|
||||||
import { store } from "../../store/state.svelte";
|
|
||||||
import type { Extension } from "../../lib/types";
|
|
||||||
|
|
||||||
type Filter = "installed" | "available" | "updates" | "all";
|
|
||||||
type Panel = null | "apk" | "repos";
|
|
||||||
|
|
||||||
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
|
|
||||||
|
|
||||||
let extensions: Extension[] = $state([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let refreshing = $state(false);
|
|
||||||
let filter: Filter = $state("installed");
|
|
||||||
let search = $state("");
|
|
||||||
let working = $state(new Set<string>());
|
|
||||||
let expanded = $state(new Set<string>());
|
|
||||||
let panel: Panel = $state(null);
|
|
||||||
let externalUrl = $state("");
|
|
||||||
let installing = $state(false);
|
|
||||||
let installError: string|null = $state(null);
|
|
||||||
let installSuccess = $state(false);
|
|
||||||
let repos: string[] = $state([]);
|
|
||||||
let reposLoading = $state(false);
|
|
||||||
let newRepoUrl = $state("");
|
|
||||||
let repoError: string|null = $state(null);
|
|
||||||
let savingRepos = $state(false);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
|
||||||
.then((d) => extensions = d.extensions.nodes).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromRepo() {
|
|
||||||
refreshing = true;
|
|
||||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
|
||||||
.then((d) => extensions = d.fetchExtensions.extensions).catch(console.error)
|
|
||||||
.finally(() => refreshing = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepos() {
|
|
||||||
reposLoading = true;
|
|
||||||
try { const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS); repos = d.settings.extensionRepos ?? []; }
|
|
||||||
catch (e) { console.error(e); } finally { reposLoading = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRepos(updated: string[]) {
|
|
||||||
savingRepos = true;
|
|
||||||
try { const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(SET_EXTENSION_REPOS, { repos: updated }); repos = d.setSettings.settings.extensionRepos; }
|
|
||||||
catch (e: any) { repoError = e instanceof Error ? e.message : "Failed to save"; } finally { savingRepos = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRepo() {
|
|
||||||
const url = newRepoUrl.trim();
|
|
||||||
if (!url) return;
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) { repoError = "URL must start with http:// or https://"; return; }
|
|
||||||
if (repos.includes(url)) { repoError = "Repo already added"; return; }
|
|
||||||
repoError = null; newRepoUrl = "";
|
|
||||||
saveRepos([...repos, url]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRepo(url: string) { saveRepos(repos.filter((r) => r !== url)); }
|
|
||||||
|
|
||||||
async function mutate(fn: () => Promise<unknown>, pkgName: string) {
|
|
||||||
working = new Set(working).add(pkgName);
|
|
||||||
await fn().catch(console.error);
|
|
||||||
await load();
|
|
||||||
working.delete(pkgName); working = new Set(working);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installExternal() {
|
|
||||||
const url = externalUrl.trim();
|
|
||||||
if (!url) return;
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) { installError = "URL must start with http:// or https://"; return; }
|
|
||||||
if (!url.endsWith(".apk")) { installError = "URL must point to an .apk file"; return; }
|
|
||||||
installing = true; installError = null; installSuccess = false;
|
|
||||||
try {
|
|
||||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
|
||||||
installSuccess = true; externalUrl = "";
|
|
||||||
await load();
|
|
||||||
setTimeout(() => { panel = null; installSuccess = false; }, 1500);
|
|
||||||
} catch (e: any) { installError = e instanceof Error ? e.message : "Install failed"; }
|
|
||||||
finally { installing = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPanel(p: Panel) {
|
|
||||||
panel = panel === p ? null : p;
|
|
||||||
installError = null; installSuccess = false; externalUrl = "";
|
|
||||||
repoError = null; newRepoUrl = "";
|
|
||||||
if (p === "repos") loadRepos();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); });
|
|
||||||
|
|
||||||
const filtered = $derived(extensions.filter((e) => {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
|
||||||
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
|
|
||||||
return matchSearch && matchFilter;
|
|
||||||
}));
|
|
||||||
|
|
||||||
const groups = $derived.by(() => {
|
|
||||||
const map = new Map<string, Extension[]>();
|
|
||||||
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
|
|
||||||
const preferredLang = store.settings.preferredExtensionLang;
|
|
||||||
return Array.from(map.entries()).map(([base, all]) => {
|
|
||||||
const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0];
|
|
||||||
return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
|
|
||||||
|
|
||||||
const FILTERS: { id: Filter; label: string }[] = [
|
|
||||||
{ id: "installed", label: "Installed" },
|
|
||||||
{ id: "available", label: "Available" },
|
|
||||||
{ id: "updates", label: "Updates" },
|
|
||||||
{ id: "all", label: "All" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function toggleExpand(base: string) {
|
|
||||||
const next = new Set(expanded);
|
|
||||||
next.has(base) ? next.delete(base) : next.add(base);
|
|
||||||
expanded = next;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="heading">Extensions</h1>
|
|
||||||
<div class="tabs">
|
|
||||||
{#each FILTERS as f}
|
|
||||||
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
|
||||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search" bind:value={search} />
|
|
||||||
</div>
|
|
||||||
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
|
|
||||||
<GitBranch size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
|
|
||||||
<Plus size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
|
||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if panel === "apk"}
|
|
||||||
<div class="ext-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">Install from APK URL</span>
|
|
||||||
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<div class="ext-row">
|
|
||||||
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
|
|
||||||
bind:value={externalUrl} disabled={installing}
|
|
||||||
oninput={() => installError = null}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} use:focusOnMount />
|
|
||||||
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
|
||||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
|
||||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
|
||||||
{:else}Install{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if installError}<div class="panel-error">{installError}</div>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if panel === "repos"}
|
|
||||||
<div class="ext-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">Extension Repositories</span>
|
|
||||||
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
{#if reposLoading}
|
|
||||||
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{:else}
|
|
||||||
{#if repos.length === 0}
|
|
||||||
<div class="repo-empty">No repos configured.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="repo-list">
|
|
||||||
{#each repos as url}
|
|
||||||
<div class="repo-row">
|
|
||||||
<span class="repo-url">{url}</span>
|
|
||||||
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
|
||||||
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="ext-row" style="margin-top:var(--sp-2)">
|
|
||||||
<input class="ext-input" class:error={repoError} placeholder="https://example.com/index.min.json"
|
|
||||||
bind:value={newRepoUrl} disabled={savingRepos}
|
|
||||||
oninput={() => repoError = null}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
|
|
||||||
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
|
||||||
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{:else if groups.length === 0}
|
|
||||||
<div class="empty">No extensions found.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="list">
|
|
||||||
{#each groups as { base, primary, variants }}
|
|
||||||
{@const isExpanded = expanded.has(base)}
|
|
||||||
{@const hasVariants = variants.length > 0}
|
|
||||||
<div class="group">
|
|
||||||
<div class="row">
|
|
||||||
<Thumbnail src={primary.iconUrl} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
|
||||||
<div class="info">
|
|
||||||
<span class="name">{base}</span>
|
|
||||||
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
|
||||||
</div>
|
|
||||||
{#if working.has(primary.pkgName)}
|
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
{:else if primary.hasUpdate}
|
|
||||||
<div class="row-actions">
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
|
|
||||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
|
||||||
</div>
|
|
||||||
{:else if primary.isInstalled}
|
|
||||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
|
||||||
{:else}
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
|
|
||||||
{/if}
|
|
||||||
{#if hasVariants}
|
|
||||||
<button class="expand-btn" onclick={() => toggleExpand(base)} title="{variants.length + 1} languages">
|
|
||||||
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
|
||||||
<span class="expand-count">{variants.length + 1}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if isExpanded && hasVariants}
|
|
||||||
<div class="variants">
|
|
||||||
{#each variants as v}
|
|
||||||
<div class="variant-row">
|
|
||||||
<span class="lang-tag">{v.lang.toUpperCase()}</span>
|
|
||||||
<span class="variant-name">{v.name}</span>
|
|
||||||
<span class="variant-version">v{v.versionName}</span>
|
|
||||||
{#if v.hasUpdate}<span class="update-badge-small">↑</span>{/if}
|
|
||||||
<div class="variant-actions">
|
|
||||||
{#if working.has(v.pkgName)}
|
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
{:else if v.hasUpdate}
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
|
|
||||||
{:else if v.isInstalled}
|
|
||||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
|
|
||||||
{:else}
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.icon-btn:disabled { opacity: 0.4; }
|
|
||||||
.icon-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
|
||||||
.panel-header { display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.panel-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
|
||||||
.panel-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); padding: 0 2px; }
|
|
||||||
.ext-row { display: flex; gap: var(--sp-2); }
|
|
||||||
.ext-input { flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 6px var(--sp-3); color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
|
||||||
.ext-input:focus { border-color: var(--border-focus); }
|
|
||||||
.ext-input:disabled { opacity: 0.5; }
|
|
||||||
.ext-input.error { border-color: var(--color-error) !important; }
|
|
||||||
.install-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 14px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base), opacity var(--t-base); white-space: nowrap; }
|
|
||||||
.install-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.install-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.install-btn.success { background: rgba(107,143,107,0.2); border-color: var(--accent-fg); color: var(--accent-fg); }
|
|
||||||
.repo-loading { display: flex; align-items: center; justify-content: center; padding: var(--sp-3); }
|
|
||||||
.repo-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 2px; }
|
|
||||||
.repo-list { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.repo-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
|
||||||
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
|
||||||
|
|
||||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
|
||||||
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
.group { display: flex; flex-direction: column; }
|
|
||||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
|
||||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
|
||||||
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
|
||||||
.action-btn:hover { filter: brightness(1.1); }
|
|
||||||
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.action-btn-dim:hover { color: var(--color-error); border-color: var(--color-error); }
|
|
||||||
.expand-btn { display: flex; align-items: center; gap: 3px; padding: 4px 6px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.expand-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.expand-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.variants { display: flex; flex-direction: column; gap: 1px; margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3)); padding-left: var(--sp-3); border-left: 1px solid var(--border-dim); animation: fadeIn 0.1s ease both; }
|
|
||||||
.variant-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
|
||||||
.variant-row:hover { background: var(--bg-raised); }
|
|
||||||
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.variant-actions { flex-shrink: 0; }
|
|
||||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
@@ -1,675 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { getBlobUrl } from "../../lib/imageCache";
|
|
||||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
|
||||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
|
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
|
||||||
import { buildReaderChapterList } from "../../lib/chapterList";
|
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "Just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReadTime(mins: number): string {
|
|
||||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
|
||||||
if (mins < 60) return `${Math.round(mins)}m`;
|
|
||||||
const h = Math.floor(mins / 60), r = Math.round(mins % 60);
|
|
||||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
|
||||||
const d = Math.floor(h / 24), rh = h % 24;
|
|
||||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusEl(node: HTMLElement) { node.focus(); }
|
|
||||||
|
|
||||||
let libraryManga: Manga[] = $state([]);
|
|
||||||
let extraManga: Manga[] = $state([]);
|
|
||||||
let loadingLibrary: boolean = $state(true);
|
|
||||||
let completedCategory: Category | null = $state(null);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
loadLibrary();
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAndReload() {
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
loadingLibrary = true;
|
|
||||||
heroChapters = [];
|
|
||||||
heroAllChapters = [];
|
|
||||||
heroChaptersFor = null;
|
|
||||||
loadLibrary();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (store.navPage === "home") untrack(() => resetAndReload());
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const sessionId = store.readerSessionId;
|
|
||||||
if (sessionId === 0) return;
|
|
||||||
untrack(() => resetAndReload());
|
|
||||||
});
|
|
||||||
|
|
||||||
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)));
|
|
||||||
const valid = results.flatMap(r => r.status === "fulfilled" && r.value ? [r.value] : []);
|
|
||||||
if (valid.length) extraManga = valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
const continueReading = $derived((() => {
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const out: HistoryEntry[] = [];
|
|
||||||
for (const e of store.history) {
|
|
||||||
if (seen.has(e.mangaId)) continue;
|
|
||||||
seen.add(e.mangaId);
|
|
||||||
out.push(e);
|
|
||||||
if (out.length >= 10) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
})());
|
|
||||||
|
|
||||||
const TOTAL_SLOTS = 4;
|
|
||||||
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
|
|
||||||
|
|
||||||
const resolvedSlots = $derived((() => {
|
|
||||||
const pins = store.settings.heroSlots ?? [null, null, null, null];
|
|
||||||
const slots: HeroSlot[] = [];
|
|
||||||
const first = continueReading[0];
|
|
||||||
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
|
|
||||||
let hi = 1;
|
|
||||||
for (let i = 1; i < TOTAL_SLOTS; i++) {
|
|
||||||
const pinId = pins[i];
|
|
||||||
if (pinId != null) {
|
|
||||||
const manga = libraryManga.find(m => m.id === pinId);
|
|
||||||
if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; }
|
|
||||||
}
|
|
||||||
const entry = continueReading[hi++];
|
|
||||||
slots.push(entry ? { kind: "continue", entry, slotIndex: i } : { kind: "empty", slotIndex: i });
|
|
||||||
}
|
|
||||||
return slots;
|
|
||||||
})());
|
|
||||||
|
|
||||||
let activeIdx = $state(0);
|
|
||||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
|
||||||
const heroThumbSrc = $derived(
|
|
||||||
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
|
|
||||||
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
|
|
||||||
);
|
|
||||||
let heroThumb = $state("");
|
|
||||||
$effect(() => {
|
|
||||||
const path = heroThumbSrc;
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (!path) { heroThumb = ""; return; }
|
|
||||||
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
|
|
||||||
// Use tauri-plugin-http backed getBlobUrl which handles auth and bypasses CORS
|
|
||||||
getBlobUrl(thumbUrl(path))
|
|
||||||
.then(url => { heroThumb = url; })
|
|
||||||
.catch(() => { heroThumb = ""; });
|
|
||||||
});
|
|
||||||
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
|
|
||||||
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
|
|
||||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
|
||||||
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
|
||||||
|
|
||||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
|
||||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
|
||||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
|
||||||
if (e.key === "ArrowRight") cycleNext();
|
|
||||||
if (e.key === "ArrowLeft") cyclePrev();
|
|
||||||
}
|
|
||||||
onMount(() => {
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
let heroStageH = $state(300);
|
|
||||||
let heroChapters: Chapter[] = $state([]);
|
|
||||||
let heroAllChapters: Chapter[] = $state([]);
|
|
||||||
let loadingHeroChapters = $state(false);
|
|
||||||
let heroChaptersFor: number | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = heroMangaId;
|
|
||||||
void store.settings.mangaPrefs?.[id!];
|
|
||||||
if (id) untrack(() => loadHeroChapters(id));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadHeroChapters(mangaId: number) {
|
|
||||||
heroChaptersFor = mangaId;
|
|
||||||
loadingHeroChapters = true;
|
|
||||||
heroChapters = [];
|
|
||||||
heroAllChapters = [];
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
|
||||||
if (heroChaptersFor !== mangaId) return;
|
|
||||||
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
heroAllChapters = all;
|
|
||||||
const filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
|
|
||||||
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
|
|
||||||
const startIdx = Math.max(0, lastReadIdx);
|
|
||||||
heroChapters = filtered.slice(startIdx, startIdx + 5);
|
|
||||||
} catch { heroChapters = []; heroAllChapters = []; }
|
|
||||||
finally { loadingHeroChapters = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let resuming = $state(false);
|
|
||||||
|
|
||||||
async function openChapter(chapter: Chapter) {
|
|
||||||
if (!heroMangaId) return;
|
|
||||||
resuming = true;
|
|
||||||
try {
|
|
||||||
let all = heroAllChapters;
|
|
||||||
if (!all.length) {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
|
||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
}
|
|
||||||
if (all.length) {
|
|
||||||
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
|
|
||||||
store.activeManga = manga;
|
|
||||||
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
|
|
||||||
const target = list.find(c => c.id === chapter.id) ?? list[0];
|
|
||||||
if (target) openReader(target, list);
|
|
||||||
}
|
|
||||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
|
||||||
finally { resuming = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeActive() {
|
|
||||||
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
|
||||||
if (!heroEntry) return;
|
|
||||||
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
|
|
||||||
if (target && heroAllChapters.length) { await openChapter(target); return; }
|
|
||||||
resuming = true;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
|
||||||
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
|
|
||||||
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
|
|
||||||
if (ch) {
|
|
||||||
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
|
||||||
openReader(ch, list);
|
|
||||||
}
|
|
||||||
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
|
||||||
finally { resuming = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeEntry(entry: HistoryEntry) {
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
|
||||||
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
|
||||||
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
|
||||||
if (ch) {
|
|
||||||
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
|
||||||
openReader(ch, list);
|
|
||||||
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
|
||||||
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let pickerOpen = $state(false);
|
|
||||||
let pickerSlotIndex: 1|2|3|null = $state(null);
|
|
||||||
let pickerSearch = $state("");
|
|
||||||
|
|
||||||
const pickerResults = $derived(pickerSearch.trim()
|
|
||||||
? libraryManga.filter(m => m.title.toLowerCase().includes(pickerSearch.toLowerCase())).slice(0, 20)
|
|
||||||
: libraryManga.slice(0, 20));
|
|
||||||
|
|
||||||
function openPicker(i: 1|2|3) { pickerSlotIndex = i; pickerOpen = true; pickerSearch = ""; }
|
|
||||||
function closePicker() { pickerOpen = false; pickerSlotIndex = null; }
|
|
||||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
|
||||||
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
|
||||||
|
|
||||||
const 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, 7) : []);
|
|
||||||
const recentHistory = $derived(store.history.slice(0, 6));
|
|
||||||
const stats = $derived(store.readingStats);
|
|
||||||
|
|
||||||
function handleRowWheel(e: WheelEvent) {
|
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
||||||
(e.currentTarget as HTMLElement).scrollLeft += e.deltaY;
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="body">
|
|
||||||
|
|
||||||
<div class="hero-section">
|
|
||||||
<div class="hero-stage" bind:clientHeight={heroStageH} style="--hero-h:{heroStageH}px">
|
|
||||||
|
|
||||||
{#if heroThumb}
|
|
||||||
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
|
|
||||||
{:else}
|
|
||||||
<div class="hero-backdrop hero-bd-empty"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="hero-scrim"></div>
|
|
||||||
|
|
||||||
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
|
|
||||||
{#if heroThumb}
|
|
||||||
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<div class="cover-resume-hint"><Play size={18} weight="fill" /></div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="hero-details">
|
|
||||||
{#if activeSlot?.kind === "empty"}
|
|
||||||
<p class="hero-empty-title">Nothing here yet</p>
|
|
||||||
<p class="hero-empty-sub">{activeSlot.slotIndex === 0 ? "Read a manga to see it here" : "Pin a manga or keep reading to fill this slot"}</p>
|
|
||||||
{#if activeSlot.slotIndex !== 0}
|
|
||||||
<button class="hero-cta" onclick={() => openPicker(activeSlot.slotIndex as 1|2|3)}>
|
|
||||||
<PushPin size={11} weight="fill" /> Pin manga
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="hero-tags">
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
|
|
||||||
{:else}
|
|
||||||
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
|
|
||||||
{/if}
|
|
||||||
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
|
|
||||||
<button class="hero-tag hero-tag-genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); }}>{g}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="hero-title">{heroTitle}</h2>
|
|
||||||
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
|
|
||||||
|
|
||||||
{#if heroEntry}
|
|
||||||
<p class="hero-progress">
|
|
||||||
<Clock size={10} weight="light" />
|
|
||||||
{heroEntry.chapterName}
|
|
||||||
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
|
|
||||||
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if heroManga?.description}<p class="hero-desc">{heroManga.description}</p>{/if}
|
|
||||||
|
|
||||||
<div class="hero-actions">
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<button class="hero-cta" onclick={resumeActive} disabled={resuming}>
|
|
||||||
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
|
|
||||||
</button>
|
|
||||||
{:else if heroManga}
|
|
||||||
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
|
|
||||||
<BookOpen size={11} weight="light" /> View manga
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if activeSlot?.slotIndex !== 0}
|
|
||||||
{#if activeSlot?.kind === "pinned"}
|
|
||||||
<button class="hero-cta-ghost" onclick={() => unpinSlot(activeSlot.slotIndex as 1|2|3)}>
|
|
||||||
<XIcon size={10} weight="bold" /> Unpin
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button class="hero-cta-ghost" onclick={() => openPicker(activeSlot!.slotIndex as 1|2|3)}>
|
|
||||||
<PushPin size={10} weight="light" /> Pin
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="hero-nav-row">
|
|
||||||
<button class="hero-nav-btn" onclick={cyclePrev} aria-label="Previous"><ArrowLeft size={12} weight="bold" /></button>
|
|
||||||
<div class="hero-dots">
|
|
||||||
{#each resolvedSlots as slot, i}
|
|
||||||
<button class="hero-dot" class:active={activeIdx === i} class:pinned={slot.kind === "pinned"} onclick={() => goToSlot(i)} aria-label="Slot {i + 1}"></button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="hero-nav-btn" onclick={cycleNext} aria-label="Next"><ArrowRight size={12} weight="bold" /></button>
|
|
||||||
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-chapters">
|
|
||||||
<div class="hero-chapters-header"><ListBullets size={11} weight="bold" /><span>Up Next</span></div>
|
|
||||||
|
|
||||||
{#if activeSlot?.kind === "empty"}
|
|
||||||
<p class="hero-chapters-empty">No chapters to show</p>
|
|
||||||
{:else if loadingHeroChapters}
|
|
||||||
{#each Array(4) as _}
|
|
||||||
<div class="chapter-row-sk">
|
|
||||||
<div class="sk sk-num"></div>
|
|
||||||
<div class="sk-info"><div class="sk sk-name"></div><div class="sk sk-meta"></div></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{:else if heroChapters.length === 0}
|
|
||||||
<p class="hero-chapters-empty">No chapters available</p>
|
|
||||||
{:else}
|
|
||||||
{#each heroChapters as ch (ch.id)}
|
|
||||||
{@const isCurrent = heroEntry?.chapterId === ch.id}
|
|
||||||
<button class="chapter-row" class:chapter-row-current={isCurrent} class:chapter-row-read={ch.isRead && !isCurrent} onclick={() => openChapter(ch)}>
|
|
||||||
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
|
|
||||||
<div class="ch-info">
|
|
||||||
<span class="ch-name">{ch.name}</span>
|
|
||||||
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
|
|
||||||
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
|
|
||||||
{:else if ch.isRead}
|
|
||||||
<span class="ch-meta ch-read">Read</span>
|
|
||||||
{:else if ch.uploadDate}
|
|
||||||
<span class="ch-meta">{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate)*1000).toLocaleDateString("en-US",{month:"short",day:"numeric"})}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if heroManga}
|
|
||||||
<button class="ch-view-all" onclick={() => { if (heroManga) store.activeManga = heroManga; }}>
|
|
||||||
All chapters <ArrowRight size={9} weight="bold" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
|
|
||||||
{#if recentHistory.length > 0}
|
|
||||||
<button class="see-all" onclick={() => setNavPage("history")}>Full History <ArrowRight size={9} weight="bold" /></button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="activity-list">
|
|
||||||
{#if recentHistory.length > 0}
|
|
||||||
{#each recentHistory as entry (entry.chapterId)}
|
|
||||||
<button class="activity-row" onclick={() => resumeEntry(entry)}>
|
|
||||||
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="activity-thumb" />
|
|
||||||
<div class="activity-info">
|
|
||||||
<span class="activity-title">{entry.mangaTitle}</span>
|
|
||||||
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
|
|
||||||
</div>
|
|
||||||
<span class="activity-time">{timeAgo(entry.readAt)}</span>
|
|
||||||
<span class="activity-play"><Play size={10} weight="fill" /></span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<div class="activity-placeholder">
|
|
||||||
{#each Array(5) as _, i}
|
|
||||||
<div class="activity-row activity-row-sk">
|
|
||||||
<div class="sk-thumb"></div>
|
|
||||||
<div class="activity-info">
|
|
||||||
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
|
|
||||||
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="sk sk-time"></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<div class="activity-placeholder-overlay">
|
|
||||||
<button class="activity-placeholder-cta" onclick={() => setNavPage("library")}>
|
|
||||||
<BookOpen size={12} weight="light" /> Start reading
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-row">
|
|
||||||
<div class="bottom-col">
|
|
||||||
<div class="bottom-section-hd">
|
|
||||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
|
||||||
{#if completedManga.length > 0}
|
|
||||||
<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}
|
|
||||||
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
|
||||||
{#each completedManga as m (m.id)}
|
|
||||||
<button class="mini-card" onclick={() => store.previewManga = m}>
|
|
||||||
<div class="mini-cover-wrap">
|
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="mini-cover" />
|
|
||||||
<div class="mini-gradient"></div>
|
|
||||||
<div class="mini-footer">
|
|
||||||
<p class="mini-card-title">{m.title}</p>
|
|
||||||
{#if m.source?.displayName}<p class="mini-card-source">{m.source.displayName}</p>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="bottom-empty">Finish a manga to see it here</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-divider"></div>
|
|
||||||
|
|
||||||
<div class="bottom-col">
|
|
||||||
<div class="bottom-section-hd">
|
|
||||||
<span class="section-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
|
|
||||||
</div>
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-fire"><Fire size={16} weight="fill" /></div><div class="stat-body"><span class="stat-val">{stats.currentStreakDays}</span><span class="stat-label">Day streak</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-accent"><BookOpen size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalChaptersRead}</span><span class="stat-label">Chapters read</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><Clock size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span><span class="stat-label">Read time</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><TrendUp size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalMangaRead}</span><span class="stat-label">Series started</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-green"><CheckCircle size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{completedIds.length}</span><span class="stat-label">Completed</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.longestStreakDays}d</span><span class="stat-label">Best streak</span></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if pickerOpen}
|
|
||||||
<div class="picker-backdrop" role="presentation"
|
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}
|
|
||||||
onkeydown={(e) => { if (e.key === "Escape") closePicker(); }}>
|
|
||||||
<div class="picker-modal">
|
|
||||||
<div class="picker-header">
|
|
||||||
<span class="picker-title">Pin manga — slot {(pickerSlotIndex ?? 0) + 1}</span>
|
|
||||||
<button class="picker-close" onclick={closePicker}><XIcon size={13} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<div class="picker-search-wrap">
|
|
||||||
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
|
||||||
<input class="picker-search" placeholder="Search library…" bind:value={pickerSearch} use:focusEl />
|
|
||||||
</div>
|
|
||||||
<div class="picker-list">
|
|
||||||
{#if loadingLibrary}
|
|
||||||
<p class="picker-empty">Loading…</p>
|
|
||||||
{:else if pickerResults.length === 0}
|
|
||||||
<p class="picker-empty">No results</p>
|
|
||||||
{:else}
|
|
||||||
{#each pickerResults as m (m.id)}
|
|
||||||
<button class="picker-row" onclick={() => pinManga(m)}>
|
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="picker-thumb" />
|
|
||||||
<div class="picker-info">
|
|
||||||
<span class="picker-manga-title">{m.title}</span>
|
|
||||||
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.body { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; min-height: 0; }
|
|
||||||
.hero-section { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; }
|
|
||||||
.hero-stage { position: relative; display: flex; align-items: stretch; height: 374px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
|
|
||||||
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(20px) saturate(2.2) brightness(0.45); transform: scale(1.07); pointer-events: none; z-index: 0; }
|
|
||||||
.hero-bd-empty { background: var(--bg-void); filter: none; }
|
|
||||||
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.55) 100%); }
|
|
||||||
.hero-cover-col { position: relative; z-index: 2; flex-shrink: 0; width: 263px; height: 374px; overflow: hidden; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.08); background: var(--bg-raised); }
|
|
||||||
.hero-cover-col:hover .hero-cover { filter: brightness(1.08); }
|
|
||||||
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
|
|
||||||
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.2s ease; }
|
|
||||||
.hero-cover-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); color: var(--text-faint); }
|
|
||||||
.cover-resume-hint { position: absolute; inset: var(--sp-3); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 36px; background: rgba(0,0,0,0.4); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
|
|
||||||
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-4) var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
|
|
||||||
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
|
|
||||||
.hero-tag { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); border: 1px solid rgba(255,255,255,0.14); }
|
|
||||||
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
|
||||||
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
|
|
||||||
.hero-tag-genre { cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
|
|
||||||
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
|
|
||||||
.hero-title { font-size: var(--text-xl); font-weight: var(--weight-medium); color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 2px 10px rgba(0,0,0,0.5); }
|
|
||||||
.hero-author { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.48); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.hero-progress { display: flex; align-items: center; gap: 5px; flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.58); letter-spacing: var(--tracking-wide); }
|
|
||||||
.hero-prog-page { color: rgba(255,255,255,0.38); }
|
|
||||||
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.32); }
|
|
||||||
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0; }
|
|
||||||
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.5); flex-shrink: 0; }
|
|
||||||
.hero-empty-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
|
||||||
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
|
|
||||||
.hero-cta { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); white-space: nowrap; }
|
|
||||||
.hero-cta:hover:not(:disabled) { filter: brightness(1.15); }
|
|
||||||
.hero-cta:disabled { opacity: 0.55; cursor: default; }
|
|
||||||
.hero-cta-ghost { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 14px; border-radius: var(--radius-full); background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.13); color: rgba(255,255,255,0.52); cursor: pointer; transition: background var(--t-base), color var(--t-base); white-space: nowrap; }
|
|
||||||
.hero-cta-ghost:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.82); }
|
|
||||||
.hero-nav-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; margin-top: auto; padding-top: var(--sp-2); border-top: 1px solid rgba(255,255,255,0.08); }
|
|
||||||
.hero-nav-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.12); color: rgba(255,255,255,0.6); cursor: pointer; flex-shrink: 0; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.hero-nav-btn:hover { background: rgba(255,255,255,0.2); color: #fff; }
|
|
||||||
.hero-dots { display: flex; gap: 5px; align-items: center; }
|
|
||||||
.hero-dot { width: 5px; height: 5px; border-radius: 50%; background: rgba(255,255,255,0.22); border: none; cursor: pointer; padding: 0; transition: background var(--t-base), transform var(--t-base); }
|
|
||||||
.hero-dot:hover { background: rgba(255,255,255,0.5); }
|
|
||||||
.hero-dot.active { background: #fff; transform: scale(1.35); }
|
|
||||||
.hero-dot.pinned { background: rgba(168,132,232,0.55); }
|
|
||||||
.hero-dot.pinned.active { background: #c4a8f0; }
|
|
||||||
.hero-counter { font-family: var(--font-ui); font-size: 10px; color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); margin-left: auto; }
|
|
||||||
.hero-chapters { position: relative; z-index: 2; width: clamp(180px, 32%, 240px); flex-shrink: 0; display: flex; flex-direction: column; padding: var(--sp-4) var(--sp-3); gap: 1px; overflow: hidden; }
|
|
||||||
.hero-chapters-header { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.4); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding-bottom: var(--sp-2); margin-bottom: var(--sp-1); border-bottom: 1px solid rgba(255,255,255,0.08); flex-shrink: 0; }
|
|
||||||
.hero-chapters-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.25); letter-spacing: var(--tracking-wide); padding: var(--sp-3) 0; }
|
|
||||||
.chapter-row { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
|
||||||
.chapter-row:hover { background: rgba(255,255,255,0.07); }
|
|
||||||
.chapter-row-current { background: rgba(255,255,255,0.1) !important; }
|
|
||||||
.ch-num { font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.35); letter-spacing: var(--tracking-wide); flex-shrink: 0; min-width: 36px; }
|
|
||||||
.chapter-row-current .ch-num { color: var(--accent-fg); }
|
|
||||||
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.ch-name { font-size: var(--text-xs); color: rgba(255,255,255,0.75); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.chapter-row-read .ch-name { color: rgba(255,255,255,0.35); }
|
|
||||||
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
|
|
||||||
.ch-meta { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); }
|
|
||||||
.ch-read { color: rgba(255,255,255,0.2); }
|
|
||||||
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
|
|
||||||
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
|
|
||||||
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.sk { background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
|
|
||||||
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
|
|
||||||
.sk-name { height: 11px; width: 85%; }
|
|
||||||
.sk-meta { height: 9px; width: 50%; }
|
|
||||||
.ch-view-all { display: flex; align-items: center; gap: 4px; margin-top: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); background: none; border: none; cursor: pointer; padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base); }
|
|
||||||
.ch-view-all:hover { color: var(--accent-fg); }
|
|
||||||
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-2); }
|
|
||||||
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
|
||||||
.see-all:hover { color: var(--accent-fg); }
|
|
||||||
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
|
|
||||||
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
|
|
||||||
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.activity-row:hover .activity-play { opacity: 1; }
|
|
||||||
:global(.activity-thumb) { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
|
||||||
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.activity-time { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.activity-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
|
||||||
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
|
||||||
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-4); padding-bottom: var(--sp-5); }
|
|
||||||
.bottom-col:first-child { padding-right: var(--sp-4); }
|
|
||||||
.bottom-col:last-child { padding-left: var(--sp-4); }
|
|
||||||
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
|
||||||
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
|
||||||
.mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
|
|
||||||
.mini-row::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
.mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.mini-card:hover :global(.mini-cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
|
||||||
.mini-card:hover { will-change: transform; }
|
|
||||||
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
|
|
||||||
:global(.mini-cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
|
||||||
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
|
|
||||||
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
|
||||||
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
|
||||||
.mini-card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
|
|
||||||
.stat-card { display: flex; align-items: center; gap: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-3); }
|
|
||||||
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
|
||||||
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
|
|
||||||
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
|
|
||||||
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
|
|
||||||
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-lg, 1.05rem); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
|
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
|
||||||
.activity-row-sk { cursor: default; pointer-events: none; }
|
|
||||||
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
|
|
||||||
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); }
|
|
||||||
.sk-title { height: 11px; margin-bottom: 5px; }
|
|
||||||
.sk-sub { height: 9px; }
|
|
||||||
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
|
|
||||||
.activity-placeholder { position: relative; }
|
|
||||||
.activity-placeholder-overlay { position: absolute; left: 0; right: 0; top: 0; bottom: -1px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: var(--sp-4); pointer-events: none; background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%); }
|
|
||||||
.activity-placeholder-cta { pointer-events: all; display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: rgba(255,255,255,0.65); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.activity-placeholder-cta:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.9); }
|
|
||||||
.picker-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
|
||||||
.picker-modal { width: min(460px, calc(100vw - 48px)); max-height: 68vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
|
||||||
.picker-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.picker-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.picker-close { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; }
|
|
||||||
.picker-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.picker-search-wrap { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.picker-search { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); }
|
|
||||||
.picker-search::placeholder { color: var(--text-faint); }
|
|
||||||
.picker-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
|
||||||
.picker-list::-webkit-scrollbar { display: none; }
|
|
||||||
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
|
|
||||||
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
|
||||||
.picker-row:hover { background: var(--bg-raised); }
|
|
||||||
:global(.picker-thumb) { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
|
|
||||||
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.7 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,894 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
|
|
||||||
import { gql } from "../../lib/client";
|
|
||||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
|
||||||
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";
|
|
||||||
|
|
||||||
interface TrackerWithRecords extends Tracker {
|
|
||||||
trackRecords: { nodes: TrackRecord[] };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FlatRecord extends TrackRecord {
|
|
||||||
tracker: Tracker;
|
|
||||||
}
|
|
||||||
|
|
||||||
let trackers: TrackerWithRecords[] = $state([]);
|
|
||||||
let loading: boolean = $state(true);
|
|
||||||
let error: string | null = $state(null);
|
|
||||||
|
|
||||||
let activeTrackerId: number | "all" = $state("all");
|
|
||||||
let statusFilter: number | "all" = $state("all");
|
|
||||||
let searchQuery: string = $state("");
|
|
||||||
let sortBy: "title" | "status" | "score" | "progress" = $state("title");
|
|
||||||
|
|
||||||
let updatingId: number | null = $state(null);
|
|
||||||
let syncingId: number | null = $state(null);
|
|
||||||
let editingChapter: number | null = $state(null);
|
|
||||||
let chapterDraft: number = $state(0);
|
|
||||||
let confirmUnbindRecord: FlatRecord | null = $state(null);
|
|
||||||
|
|
||||||
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(); });
|
|
||||||
|
|
||||||
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,
|
|
||||||
tracker: t as Tracker,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalCount = $derived(allRecords.length);
|
|
||||||
|
|
||||||
const statusOptions = $derived.by(() => {
|
|
||||||
if (activeTrackerId === "all") {
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestUnbind(record: FlatRecord) {
|
|
||||||
confirmUnbindRecord = record;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelUnbind() {
|
|
||||||
confirmUnbindRecord = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmAndUnbind() {
|
|
||||||
if (!confirmUnbindRecord) return;
|
|
||||||
const record = confirmUnbindRecord;
|
|
||||||
confirmUnbindRecord = null;
|
|
||||||
await unbind(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreToStars(score: string | undefined, scores: string[] | undefined): number {
|
|
||||||
if (!score || !scores || scores.length === 0) return 0;
|
|
||||||
const idx = scores.indexOf(score);
|
|
||||||
if (idx < 0) return 0;
|
|
||||||
return Math.round((idx / (scores.length - 1)) * 5);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{#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"; }}
|
|
||||||
>
|
|
||||||
<Thumbnail src={t.icon} alt={t.name} class="tab-tracker-icon" />
|
|
||||||
{t.name}
|
|
||||||
<span class="tab-count">{count}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">Title</option>
|
|
||||||
<option value="status">Status</option>
|
|
||||||
<option value="score">Score</option>
|
|
||||||
<option value="progress">Progress</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-body">
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="state-center">
|
|
||||||
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
<span class="state-label">Loading…</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 connect AniList, MAL, or others.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if filtered.length === 0}
|
|
||||||
<div class="state-center">
|
|
||||||
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results." : "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-grid">
|
|
||||||
{#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}
|
|
||||||
{@const stars = scoreToStars(record.displayScore, tracker.scores)}
|
|
||||||
{@const statusName = (tracker.statuses ?? []).find(s => s.value === record.status)?.name ?? "—"}
|
|
||||||
|
|
||||||
<div class="record-card" class:record-busy={isBusy}>
|
|
||||||
|
|
||||||
<div class="card-cover-wrap">
|
|
||||||
<div class="card-cover-region"
|
|
||||||
role="button" tabindex="0"
|
|
||||||
onclick={() => openManga(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
|
||||||
title="Open in library"
|
|
||||||
>
|
|
||||||
{#if record.manga?.thumbnailUrl}
|
|
||||||
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="card-cover-img" />
|
|
||||||
{:else}
|
|
||||||
<div class="card-cover-empty"></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-top-actions">
|
|
||||||
{#if record.private}
|
|
||||||
<span class="card-badge-btn" title="Private"><Lock size={10} weight="fill" /></span>
|
|
||||||
{/if}
|
|
||||||
{#if isSyncing}
|
|
||||||
<span class="card-badge-btn">
|
|
||||||
<CircleNotch size={10} weight="light" class="anim-spin" />
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<button class="card-badge-btn" title="Sync" onclick={() => syncRecord(record)}>
|
|
||||||
<ArrowsClockwise size={10} weight="light" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if record.remoteUrl}
|
|
||||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-badge-btn" title="Open on {tracker.name}">
|
|
||||||
<ArrowSquareOut size={10} weight="light" />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<button class="card-badge-btn danger" title="Unlink" onclick={() => requestUnbind(record)} disabled={isBusy}>
|
|
||||||
<X size={10} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-tracker-badge">
|
|
||||||
<Thumbnail src={tracker.icon} alt={tracker.name} class="tracker-badge-img" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer">
|
|
||||||
<div class="card-stars">
|
|
||||||
{#each Array(5) as _, i}
|
|
||||||
<span class="star" class:star-filled={i < stars}>★</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-title-block"
|
|
||||||
role="button" tabindex="0"
|
|
||||||
onclick={() => openManga(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
|
||||||
>
|
|
||||||
<span class="card-title">{record.title}</span>
|
|
||||||
{#if record.manga?.title && record.manga.title !== record.title}
|
|
||||||
<span class="card-local-title">{record.manga.title}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-meta-row">
|
|
||||||
<select
|
|
||||||
class="status-pill"
|
|
||||||
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="score-select"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if editingChapter === record.id}
|
|
||||||
<div class="chapter-editor" role="presentation" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="chapter-editor-top">
|
|
||||||
<span class="chapter-editor-label">Chapter</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-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
|
||||||
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="progress-block clickable"
|
|
||||||
role="button" tabindex="0"
|
|
||||||
onclick={() => openChapterEditor(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
|
||||||
title="Click to edit chapter"
|
|
||||||
>
|
|
||||||
<div class="progress-labels">
|
|
||||||
<span class="progress-text">
|
|
||||||
{#if progress !== null}
|
|
||||||
Ch. {record.lastChapterRead} / {record.totalChapters}
|
|
||||||
{:else if record.lastChapterRead > 0}
|
|
||||||
Ch. {record.lastChapterRead} read
|
|
||||||
{:else}
|
|
||||||
Set chapter…
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{#if progress !== null}
|
|
||||||
<span class="progress-pct">{Math.round(progress)}%</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="progress-track">
|
|
||||||
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if confirmUnbindRecord}
|
|
||||||
{@const r = confirmUnbindRecord}
|
|
||||||
<div class="modal-backdrop" role="presentation" onclick={cancelUnbind}>
|
|
||||||
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="modal-icon">
|
|
||||||
<X size={18} weight="bold" />
|
|
||||||
</div>
|
|
||||||
<p class="modal-title">Unlink from {r.tracker.name}?</p>
|
|
||||||
<p class="modal-body">
|
|
||||||
<strong>{r.title}</strong> will be removed from your tracking list. This won't affect your progress on {r.tracker.name} itself.
|
|
||||||
</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="modal-cancel" onclick={cancelUnbind}>Cancel</button>
|
|
||||||
<button class="modal-confirm" onclick={confirmAndUnbind}>Unlink</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
display: flex; flex-direction: column; height: 100%; overflow: hidden;
|
|
||||||
animation: fadeIn 0.16s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
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;
|
|
||||||
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.tracker-tab:hover { color: var(--text-muted); }
|
|
||||||
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
|
|
||||||
:global(.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 {
|
|
||||||
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 22px 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;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
|
||||||
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.page-body {
|
|
||||||
flex: 1; overflow-y: auto; padding: var(--sp-5);
|
|
||||||
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
||||||
gap: var(--sp-4);
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.record-card {
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color var(--t-base), opacity var(--t-base), transform var(--t-base);
|
|
||||||
}
|
|
||||||
.record-card:hover {
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
.record-busy { opacity: 0.35; pointer-events: none; }
|
|
||||||
|
|
||||||
.card-cover-wrap {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-cover-region {
|
|
||||||
position: absolute; inset: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.card-cover-img) {
|
|
||||||
width: 100%; height: 100%;
|
|
||||||
object-fit: cover; display: block;
|
|
||||||
transition: transform 0.35s ease, opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
.card-cover-wrap:hover :global(.card-cover-img) {
|
|
||||||
transform: scale(1.04);
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
.card-cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
|
|
||||||
|
|
||||||
.card-stars {
|
|
||||||
display: flex; gap: 3px; align-items: center;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
.star {
|
|
||||||
font-size: 15px; line-height: 1;
|
|
||||||
color: var(--border-strong);
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.star-filled { color: #f5c518; }
|
|
||||||
|
|
||||||
.card-top-actions {
|
|
||||||
position: absolute; top: 6px; right: 6px; z-index: 2;
|
|
||||||
display: flex; gap: 2px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.card-cover-wrap:hover .card-top-actions { opacity: 1; }
|
|
||||||
|
|
||||||
.card-badge-btn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 24px; height: 24px; border-radius: var(--radius-sm);
|
|
||||||
background: rgba(0,0,0,0.6); backdrop-filter: blur(8px);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
color: rgba(255,255,255,0.75); cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.card-badge-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
|
|
||||||
.card-badge-btn.danger:hover { background: rgba(180,40,40,0.7); color: #fff; }
|
|
||||||
.card-badge-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.card-tracker-badge {
|
|
||||||
position: absolute; bottom: 9px; right: 9px; z-index: 2;
|
|
||||||
width: 22px; height: 22px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid rgba(0,0,0,0.35);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.55);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
:global(.tracker-badge-img) {
|
|
||||||
width: 100%; height: 100%;
|
|
||||||
object-fit: contain; display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Footer panel ───────────────────────────────────────────────────────── */
|
|
||||||
.card-footer {
|
|
||||||
display: flex; flex-direction: column; gap: 10px;
|
|
||||||
padding: 13px 13px 13px;
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Title */
|
|
||||||
.card-title-block {
|
|
||||||
display: flex; flex-direction: column; gap: 3px;
|
|
||||||
cursor: pointer; min-width: 0;
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
font-size: var(--text-sm); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary); line-height: 1.38;
|
|
||||||
display: -webkit-box; -webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical; overflow: hidden;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.card-title-block:hover .card-title { color: var(--accent-fg); }
|
|
||||||
.card-local-title {
|
|
||||||
font-family: var(--font-ui); font-size: 11px; color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-meta-row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill {
|
|
||||||
flex: 1; min-width: 0;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 20px 5px 9px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
color: var(--text-muted);
|
|
||||||
outline: none; cursor: pointer;
|
|
||||||
appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' 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);
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.status-pill:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
|
||||||
.status-pill:disabled { opacity: 0.35; cursor: default; }
|
|
||||||
.status-pill option { background: var(--bg-surface); color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.score-select {
|
|
||||||
flex-shrink: 0; width: 58px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 16px 5px 6px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
color: var(--text-faint);
|
|
||||||
outline: none; cursor: pointer;
|
|
||||||
appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat; background-position: right 4px center;
|
|
||||||
transition: border-color var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.score-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
|
||||||
.score-select:disabled { opacity: 0.35; cursor: default; }
|
|
||||||
.score-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.progress-block {
|
|
||||||
display: flex; flex-direction: column; gap: 7px;
|
|
||||||
}
|
|
||||||
.progress-block.clickable {
|
|
||||||
cursor: pointer; border-radius: var(--radius-sm);
|
|
||||||
padding: 4px 5px;
|
|
||||||
margin: 0 -5px;
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.progress-block.clickable:hover { background: var(--bg-overlay); }
|
|
||||||
.progress-labels {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
}
|
|
||||||
.progress-text {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.progress-pct {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.progress-track {
|
|
||||||
height: 3px; background: var(--border-strong);
|
|
||||||
border-radius: var(--radius-full); overflow: hidden;
|
|
||||||
}
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%; background: var(--accent);
|
|
||||||
border-radius: var(--radius-full); transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-editor {
|
|
||||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim); background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
|
|
||||||
.chapter-editor-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-1); }
|
|
||||||
.chapter-input {
|
|
||||||
width: 58px; background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
|
||||||
padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-primary); outline: none; text-align: center;
|
|
||||||
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: 10px; 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: 10px; letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 3px 10px; 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: 10px; letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 3px 6px; 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); }
|
|
||||||
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed; inset: 0; z-index: 200;
|
|
||||||
background: rgba(0,0,0,0.55);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
animation: fadeIn 0.12s ease both;
|
|
||||||
}
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-xl, 14px);
|
|
||||||
padding: var(--sp-6, 24px);
|
|
||||||
width: 320px; max-width: calc(100vw - 32px);
|
|
||||||
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
|
|
||||||
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
|
|
||||||
animation: modalIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
|
|
||||||
}
|
|
||||||
.modal-icon {
|
|
||||||
width: 40px; height: 40px; border-radius: 50%;
|
|
||||||
background: var(--color-error-bg, rgba(200,50,50,0.12));
|
|
||||||
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.25));
|
|
||||||
color: var(--color-error, #e05252);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.modal-title {
|
|
||||||
font-size: var(--text-sm); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-primary); text-align: center; margin: 0;
|
|
||||||
}
|
|
||||||
.modal-body {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); text-align: center; line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.modal-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
|
||||||
.modal-actions {
|
|
||||||
display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1);
|
|
||||||
}
|
|
||||||
.modal-cancel {
|
|
||||||
flex: 1;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 8px 0; border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim); background: none;
|
|
||||||
color: var(--text-muted); cursor: pointer;
|
|
||||||
transition: border-color var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.modal-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
|
||||||
.modal-confirm {
|
|
||||||
flex: 1;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 8px 0; border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.3));
|
|
||||||
background: var(--color-error-bg, rgba(200,50,50,0.1));
|
|
||||||
color: var(--color-error, #e05252); cursor: pointer;
|
|
||||||
transition: filter var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.modal-confirm:hover { filter: brightness(1.2); background: var(--color-error-bg, rgba(200,50,50,0.18)); }
|
|
||||||
|
|
||||||
@keyframes modalIn {
|
|
||||||
from { opacity: 0; transform: scale(0.92) translateY(8px); }
|
|
||||||
to { opacity: 1; transform: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
|
||||||
</script>
|
|
||||||
@@ -1,575 +0,0 @@
|
|||||||
<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" role="presentation" 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>
|
|
||||||
@@ -1,561 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
|
||||||
import { gql } from "../../lib/client";
|
|
||||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
|
||||||
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, 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([]);
|
|
||||||
let loadingDetail = $state(false);
|
|
||||||
let loadingChapters = $state(false);
|
|
||||||
let togglingLib = $state(false);
|
|
||||||
let descExpanded = $state(false);
|
|
||||||
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;
|
|
||||||
|
|
||||||
let linkPickerOpen = $state(false);
|
|
||||||
let linkSearch = $state("");
|
|
||||||
let allMangaForLink: Manga[] = $state([]);
|
|
||||||
let loadingLinkList = $state(false);
|
|
||||||
|
|
||||||
const linkedIds = $derived(store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : []);
|
|
||||||
|
|
||||||
const linkPickerResults = $derived.by(() => {
|
|
||||||
const others = allMangaForLink.filter((m) => m.id !== store.previewManga?.id);
|
|
||||||
const q = linkSearch.trim().toLowerCase();
|
|
||||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
|
||||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
|
||||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
|
||||||
return [...linked, ...rest];
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openLinkPicker() {
|
|
||||||
linkPickerOpen = true; linkSearch = "";
|
|
||||||
if (allMangaForLink.length) return;
|
|
||||||
loadingLinkList = true;
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
|
||||||
.then(d => { allMangaForLink = d.mangas.nodes; })
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => { loadingLinkList = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
|
||||||
|
|
||||||
function handleLink(other: Manga) {
|
|
||||||
if (!store.previewManga) return;
|
|
||||||
if (linkedIds.includes(other.id)) unlinkManga(store.previewManga.id, other.id);
|
|
||||||
else linkManga(store.previewManga.id, other.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let detailAbort: AbortController | null = null;
|
|
||||||
let chapterAbort: AbortController | null = null;
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
|
||||||
setPreviewManga(null);
|
|
||||||
manga = null; chapters = []; descExpanded = false;
|
|
||||||
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
|
|
||||||
|
|
||||||
const displayManga = $derived(manga ?? store.previewManga);
|
|
||||||
const totalCount = $derived(chapters.length);
|
|
||||||
const readCount = $derived(chapters.filter((c) => c.isRead).length);
|
|
||||||
const unreadCount = $derived(totalCount - readCount);
|
|
||||||
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
|
|
||||||
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
|
|
||||||
const inLibrary = $derived(manga?.inLibrary ?? store.previewManga?.inLibrary ?? false);
|
|
||||||
const scanlators = $derived([...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))]);
|
|
||||||
const uploadDates = $derived(chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d)));
|
|
||||||
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(mangaCategories.filter(c => c.id !== 0));
|
|
||||||
|
|
||||||
const continueChapter = $derived.by(() => {
|
|
||||||
if (!chapters.length) return null;
|
|
||||||
const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
|
||||||
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
|
||||||
const firstUnread = chapters.find((c) => !c.isRead);
|
|
||||||
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
|
||||||
return { ch: chapters[0], label: "Read again" };
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
|
|
||||||
|
|
||||||
async function load(id: number) {
|
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
|
||||||
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
|
||||||
detailAbort = dCtrl; chapterAbort = cCtrl;
|
|
||||||
manga = store.previewManga as Manga;
|
|
||||||
chapters = []; descExpanded = false; fetchError = null;
|
|
||||||
loadingDetail = true; loadingChapters = true;
|
|
||||||
|
|
||||||
(async (): Promise<Manga> => {
|
|
||||||
const key = CACHE_KEYS.MANGA(id);
|
|
||||||
if (cache.has(key)) return cache.get(key, () => Promise.resolve(store.previewManga as Manga)) as Promise<Manga>;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
|
|
||||||
return d.fetchManga.manga;
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === "AbortError") throw e;
|
|
||||||
const local = await gql<{ manga: Manga }>(GET_MANGA, { id }, dCtrl.signal).then((d) => d.manga);
|
|
||||||
if (local) return local;
|
|
||||||
throw new Error("Could not load manga details");
|
|
||||||
}
|
|
||||||
})().then((fullManga) => {
|
|
||||||
if (dCtrl.signal.aborted) return;
|
|
||||||
if (!cache.has(CACHE_KEYS.MANGA(id))) cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
|
||||||
manga = fullManga; loadingDetail = false;
|
|
||||||
}).catch((e) => {
|
|
||||||
if (e?.name === "AbortError") return;
|
|
||||||
manga = store.previewManga as Manga;
|
|
||||||
fetchError = "Could not load full details — showing cached data";
|
|
||||||
loadingDetail = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, cCtrl.signal)
|
|
||||||
.then(async (d) => {
|
|
||||||
if (cCtrl.signal.aborted) return;
|
|
||||||
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
try {
|
|
||||||
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal);
|
|
||||||
if (!cCtrl.signal.aborted) nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
} catch (e: any) { if (e?.name === "AbortError") return; }
|
|
||||||
}
|
|
||||||
if (!cCtrl.signal.aborted) {
|
|
||||||
chapters = nodes;
|
|
||||||
if (nodes.length > 0) checkAndMarkCompleted(id, nodes);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleLibrary() {
|
|
||||||
if (!manga) return;
|
|
||||||
togglingLib = true;
|
|
||||||
const next = !manga.inLibrary;
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
|
||||||
manga = { ...manga, inLibrary: next };
|
|
||||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
|
||||||
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(manga!));
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
togglingLib = false;
|
|
||||||
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadAll() {
|
|
||||||
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
queueingAll = true;
|
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
|
||||||
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
|
||||||
queueingAll = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSeriesDetail() {
|
|
||||||
if (!displayManga) return;
|
|
||||||
setActiveManga(displayManga);
|
|
||||||
setNavPage("library");
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFolderOutside(e: MouseEvent) {
|
|
||||||
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; }
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (folderOpen) {
|
|
||||||
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
|
|
||||||
return () => document.removeEventListener("mousedown", handleFolderOutside);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
|
|
||||||
onMount(() => window.addEventListener("keydown", onKey));
|
|
||||||
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.previewManga}
|
|
||||||
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
|
||||||
<div class="modal" role="dialog" aria-label="Manga preview">
|
|
||||||
|
|
||||||
<div class="cover-col">
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<Thumbnail src={store.previewManga.thumbnailUrl} alt={displayManga?.title} class="cover" />
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="cover-actions">
|
|
||||||
|
|
||||||
<button class="action-btn" class:active={inLibrary} onclick={toggleLibrary} disabled={togglingLib || loadingDetail}>
|
|
||||||
<span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
|
|
||||||
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="action-btn" onclick={openSeriesDetail}>
|
|
||||||
<span class="action-icon"><Books size={13} weight="light" /></span>
|
|
||||||
<span class="action-label">Series Detail</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="folder-wrap" bind:this={folderRef}>
|
|
||||||
<button class="action-btn" class:active={assignedFolders.length > 0} onclick={() => folderOpen = !folderOpen}>
|
|
||||||
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
|
|
||||||
<span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span>
|
|
||||||
</button>
|
|
||||||
{#if folderOpen}
|
|
||||||
<div class="folder-menu">
|
|
||||||
{#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>
|
|
||||||
{#if creatingFolder}
|
|
||||||
<div class="folder-create-row">
|
|
||||||
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }}
|
|
||||||
use:focusAction />
|
|
||||||
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="folder-new" onclick={() => creatingFolder = true}>+ New folder</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="action-btn" class:active={linkedIds.length > 0} onclick={openLinkPicker}>
|
|
||||||
<span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span>
|
|
||||||
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<div class="content-header">
|
|
||||||
<div class="title-block">
|
|
||||||
<h2 class="title">{displayManga?.title}</h2>
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="sk-byline"></div>
|
|
||||||
{:else if displayManga?.author || displayManga?.artist}
|
|
||||||
<p class="byline">{[displayManga?.author, displayManga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<button class="close-btn" onclick={close}><X size={15} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content-body">
|
|
||||||
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
|
|
||||||
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
|
|
||||||
{:else}
|
|
||||||
<div class="badges">
|
|
||||||
{#if statusLabel}<span class="badge" class:badge-green={displayManga?.status === "ONGOING"}>{statusLabel}</span>{/if}
|
|
||||||
{#if displayManga?.source}<span class="badge">{displayManga.source.displayName}</span>{/if}
|
|
||||||
{#if inLibrary}<span class="badge badge-accent">In Library</span>{/if}
|
|
||||||
{#if !loadingChapters && unreadCount > 0}<span class="badge badge-unread">{unreadCount} unread</span>{/if}
|
|
||||||
{#if !loadingChapters && bookmarkCount > 0}<span class="badge">{bookmarkCount} bookmarked</span>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="chapter-box">
|
|
||||||
{#if loadingChapters}
|
|
||||||
<div class="chapter-loading">
|
|
||||||
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
<span class="chapter-loading-label">Loading chapters…</span>
|
|
||||||
</div>
|
|
||||||
{:else if totalCount > 0}
|
|
||||||
<div class="chapter-meta">
|
|
||||||
<span class="chapter-label">
|
|
||||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
|
|
||||||
</span>
|
|
||||||
{#if unreadCount > 0}
|
|
||||||
<button class="dl-all-btn" onclick={downloadAll} disabled={queueingAll}>
|
|
||||||
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
|
|
||||||
{queueingAll ? "Queuing…" : "Download unread"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if readCount > 0}
|
|
||||||
<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, displayManga); close(); }}>
|
|
||||||
<Play size={12} weight="fill" />{continueChapter.label}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else if !loadingDetail}
|
|
||||||
<span class="chapter-label" style="color:var(--text-faint)">No chapters in local library</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="sk-desc">
|
|
||||||
<div class="sk-line" style="width:100%"></div>
|
|
||||||
<div class="sk-line" style="width:88%"></div>
|
|
||||||
<div class="sk-line" style="width:70%"></div>
|
|
||||||
</div>
|
|
||||||
{:else if displayManga?.description}
|
|
||||||
<div class="desc-block">
|
|
||||||
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
|
|
||||||
{#if displayManga.description.length > 220}
|
|
||||||
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>
|
|
||||||
{descExpanded ? "Show less" : "Show more"}
|
|
||||||
<CaretDown size={10} weight="light" style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loadingDetail && displayManga?.genre?.length}
|
|
||||||
<div class="genres">
|
|
||||||
{#each displayManga.genre as g}
|
|
||||||
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("explore"); close(); }}>{g}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loadingDetail}
|
|
||||||
<div class="meta-table">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#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="close-btn" 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:focusAction />
|
|
||||||
</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)}>
|
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
|
|
||||||
<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}
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusAction(node: HTMLElement) { node.focus(); }
|
|
||||||
</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; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
|
||||||
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
|
||||||
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
|
|
||||||
.cover-wrap { position: relative; width: 100%; }
|
|
||||||
:global(.cover) { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
|
||||||
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
|
||||||
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
|
||||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
|
|
||||||
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
||||||
.folder-wrap { position: relative; width: 100%; }
|
|
||||||
.folder-menu { position: absolute; bottom: calc(100% + 4px); left: 0; right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10; animation: scaleIn 0.1s ease both; transform-origin: bottom center; }
|
|
||||||
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
|
|
||||||
.folder-item { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.folder-item.folder-item-on { color: var(--accent-fg); }
|
|
||||||
.folder-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
|
||||||
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
|
|
||||||
.folder-input { flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); outline: none; min-width: 0; }
|
|
||||||
.folder-input:focus { border-color: var(--border-focus); }
|
|
||||||
.folder-ok { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base); }
|
|
||||||
.folder-ok:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
|
||||||
.folder-new { padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; width: 100%; transition: color var(--t-fast); }
|
|
||||||
.folder-new:hover { color: var(--accent-fg); }
|
|
||||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
|
||||||
.content-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
|
||||||
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
|
|
||||||
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); }
|
|
||||||
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
|
||||||
.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; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.content-body { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); scrollbar-width: none; }
|
|
||||||
.content-body::-webkit-scrollbar { display: none; }
|
|
||||||
.error-banner { font-family: var(--font-ui); font-size: var(--text-xs); color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm); padding: 6px var(--sp-3); }
|
|
||||||
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
|
|
||||||
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
|
||||||
.sk-desc { display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; }
|
|
||||||
.sk-line { height: 13px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
|
||||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
|
||||||
.badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
|
||||||
.badge-green { background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.3); color: #22c55e; }
|
|
||||||
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.badge-unread { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.3); color: #f59e0b; }
|
|
||||||
.chapter-box { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
|
|
||||||
.chapter-loading { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.chapter-loading-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.chapter-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
|
||||||
.chapter-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
|
||||||
.dl-all-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
|
||||||
.read-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 8px var(--sp-4); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; align-self: flex-start; transition: filter var(--t-base); }
|
|
||||||
.read-btn:hover { filter: brightness(1.1); }
|
|
||||||
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
|
||||||
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
|
|
||||||
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
|
|
||||||
.desc-toggle { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; align-self: flex-start; transition: color var(--t-base); }
|
|
||||||
.desc-toggle:hover { color: var(--accent-fg); }
|
|
||||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
|
||||||
.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); 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; }
|
|
||||||
.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-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; }
|
|
||||||
:global(.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); }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
|
||||||
@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 @@
|
|||||||
|
export * from "./selectPortal";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Attachment } from "svelte/attachments";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@attach selectPortal(triggerEl)}
|
||||||
|
*
|
||||||
|
* Moves the decorated element to <body> and positions it below `triggerEl`.
|
||||||
|
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
|
||||||
|
*
|
||||||
|
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
|
||||||
|
* the outside-click guard in Settings.svelte can exclude it from dismissal.
|
||||||
|
*/
|
||||||
|
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
||||||
|
return (menuEl: HTMLElement) => {
|
||||||
|
// Position & move to body
|
||||||
|
function position() {
|
||||||
|
const r = triggerEl.getBoundingClientRect();
|
||||||
|
menuEl.style.position = "fixed";
|
||||||
|
menuEl.style.top = `${r.bottom + 4}px`;
|
||||||
|
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
|
||||||
|
// clamp to viewport left edge
|
||||||
|
const left = parseFloat(menuEl.style.left);
|
||||||
|
if (left < 8) menuEl.style.left = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(menuEl);
|
||||||
|
triggerEl.__selectMenuEl = menuEl;
|
||||||
|
position();
|
||||||
|
|
||||||
|
// Reposition on scroll / resize while open
|
||||||
|
window.addEventListener("scroll", position, true);
|
||||||
|
window.addEventListener("resize", position);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", position, true);
|
||||||
|
window.removeEventListener("resize", position);
|
||||||
|
triggerEl.__selectMenuEl = null;
|
||||||
|
menuEl.remove();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Manga, Source } from "@types";
|
||||||
|
import type { Settings } from "@types";
|
||||||
|
import { shouldHideSource } from "@core/util";
|
||||||
|
|
||||||
|
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates sources by name, preferring `preferredLang` when multiple
|
||||||
|
* sources share a name. The local source (id "0") is always excluded.
|
||||||
|
*
|
||||||
|
* When `applyHide` is true, sources that fail the NSFW/block check are
|
||||||
|
* also removed — used in fan-out and cache-build paths where only
|
||||||
|
* user-visible sources should be queried.
|
||||||
|
*/
|
||||||
|
export function dedupeSourcesByLang(
|
||||||
|
sources: Source[],
|
||||||
|
preferredLang: string,
|
||||||
|
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||||
|
applyHide = false,
|
||||||
|
): Source[] {
|
||||||
|
const map = new Map<string, Source>();
|
||||||
|
for (const s of sources) {
|
||||||
|
if (s.id === "0") continue;
|
||||||
|
if (applyHide && shouldHideSource(s, settings)) continue;
|
||||||
|
const existing = map.get(s.name);
|
||||||
|
if (!existing) { map.set(s.name, s); continue; }
|
||||||
|
const existingPref = existing.lang === preferredLang;
|
||||||
|
const newPref = s.lang === preferredLang;
|
||||||
|
if (newPref && !existingPref) map.set(s.name, s);
|
||||||
|
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manga predicate filters ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic predicate pipeline — composes multiple boolean predicates into one.
|
||||||
|
* All predicates must return true for an item to pass.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const keep = buildFilter<Manga>(
|
||||||
|
* m => !shouldHideNsfw(m, settings),
|
||||||
|
* m => m.inLibrary,
|
||||||
|
* );
|
||||||
|
* const filtered = items.filter(keep);
|
||||||
|
*/
|
||||||
|
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
|
||||||
|
return (item) => predicates.every((p) => p(item));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './sort';
|
||||||
|
export * from './filter';
|
||||||
|
export * from './paginate';
|
||||||
|
export * from './search';
|
||||||
|
export * from './queue';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export interface PaginationState {
|
||||||
|
visible: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationResult<T> {
|
||||||
|
items: T[];
|
||||||
|
hasMore: boolean;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPaginator<T>(pageSize: number) {
|
||||||
|
return {
|
||||||
|
slice(all: T[], visible: number): PaginationResult<T> {
|
||||||
|
return {
|
||||||
|
items: all.slice(0, visible),
|
||||||
|
hasMore: all.length > visible,
|
||||||
|
remaining: Math.max(0, all.length - visible),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
nextVisible(current: number): number {
|
||||||
|
return current + pageSize;
|
||||||
|
},
|
||||||
|
|
||||||
|
reset(): number {
|
||||||
|
return pageSize;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export interface AsyncQueue<T> {
|
||||||
|
enqueue(item: T): void;
|
||||||
|
drain(): void;
|
||||||
|
clear(): void;
|
||||||
|
size(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAsyncQueue<T>(
|
||||||
|
worker: (item: T) => Promise<void>,
|
||||||
|
concurrency = 1,
|
||||||
|
): AsyncQueue<T> {
|
||||||
|
const queue: T[] = [];
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
while (active < concurrency && queue.length > 0) {
|
||||||
|
const item = queue.shift()!;
|
||||||
|
active++;
|
||||||
|
worker(item).finally(() => { active--; next(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue(item) { queue.push(item); next(); },
|
||||||
|
drain() { next(); },
|
||||||
|
clear() { queue.length = 0; },
|
||||||
|
size() { return queue.length; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export interface SearchResult<T> {
|
||||||
|
item: T;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchItems<T>(
|
||||||
|
items: T[],
|
||||||
|
query: string,
|
||||||
|
getField: (item: T) => string,
|
||||||
|
): T[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return items;
|
||||||
|
return items.filter(item => getField(item).toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchWithScore<T>(
|
||||||
|
items: T[],
|
||||||
|
query: string,
|
||||||
|
getField: (item: T) => string,
|
||||||
|
): SearchResult<T>[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return items.map(item => ({ item, score: 0 }));
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map(item => {
|
||||||
|
const field = getField(item).toLowerCase();
|
||||||
|
if (!field.includes(q)) return null;
|
||||||
|
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
|
||||||
|
return { item, score };
|
||||||
|
})
|
||||||
|
.filter((r): r is SearchResult<T> => r !== null)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface SortField<T> {
|
||||||
|
key: string;
|
||||||
|
comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortConfig<T> {
|
||||||
|
fields: SortField<T>[];
|
||||||
|
defaultField: string;
|
||||||
|
defaultDir: SortDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sorter<T> {
|
||||||
|
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSorter<T>(config: SortConfig<T>): Sorter<T> {
|
||||||
|
const fieldMap = new Map(config.fields.map(f => [f.key, f]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sort(items, field, dir, context) {
|
||||||
|
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField);
|
||||||
|
if (!f) return [...items];
|
||||||
|
const d = dir ?? config.defaultDir;
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const cmp = f.comparator(a, b, context);
|
||||||
|
return d === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Runs an async task over every item in `items`, with at most `concurrency`
|
||||||
|
* tasks in-flight at once. Respects the provided AbortSignal — each worker
|
||||||
|
* exits early if the signal fires. Errors thrown by individual tasks are
|
||||||
|
* swallowed so one failure does not cancel the whole batch.
|
||||||
|
*/
|
||||||
|
export async function runConcurrent<T>(
|
||||||
|
items: T[],
|
||||||
|
fn: (item: T) => Promise<void>,
|
||||||
|
signal: AbortSignal,
|
||||||
|
concurrency = 6,
|
||||||
|
): Promise<void> {
|
||||||
|
let i = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (i < items.length) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
const item = items[i++];
|
||||||
|
await fn(item).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(concurrency, items.length) }, worker),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates in-flight async calls by key.
|
||||||
|
*
|
||||||
|
* Two call signatures are supported:
|
||||||
|
*
|
||||||
|
* 1. Direct call — supply a key and a zero-arg factory each time:
|
||||||
|
* dedupeRequest("my-key", () => fetchSomething())
|
||||||
|
* If a request with that key is already pending, the existing Promise is
|
||||||
|
* returned and the factory is not called again.
|
||||||
|
*
|
||||||
|
* 2. Curried wrapper — supply a key-based fetcher once, get back a
|
||||||
|
* single-arg function you can call repeatedly:
|
||||||
|
* const get = dedupeRequest((key) => fetchSomething(key))
|
||||||
|
* get("my-key")
|
||||||
|
*/
|
||||||
|
const _inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
|
||||||
|
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
|
||||||
|
export function dedupeRequest<T>(
|
||||||
|
keyOrFn: string | ((key: string) => Promise<T>),
|
||||||
|
factory?: () => Promise<T>,
|
||||||
|
): Promise<T> | ((key: string) => Promise<T>) {
|
||||||
|
// Curried wrapper form
|
||||||
|
if (typeof keyOrFn === 'function') {
|
||||||
|
const fn = keyOrFn;
|
||||||
|
return (key: string) => dedupeRequest(key, () => fn(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct call form
|
||||||
|
const key = keyOrFn;
|
||||||
|
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>;
|
||||||
|
const p = factory!().finally(() => _inflight.delete(key));
|
||||||
|
_inflight.set(key, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export interface PaginatedQuery<T> {
|
||||||
|
fetchPage(page: number): Promise<T[]>;
|
||||||
|
reset(): void;
|
||||||
|
hasMore(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedQueryConfig<T> {
|
||||||
|
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPaginatedQuery<T>(
|
||||||
|
config: PaginatedQueryConfig<T>,
|
||||||
|
): PaginatedQuery<T> {
|
||||||
|
let _hasMore = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async fetchPage(page) {
|
||||||
|
const { items, hasNextPage } = await config.fetcher(page);
|
||||||
|
_hasMore = hasNextPage;
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
reset() { _hasMore = true; },
|
||||||
|
hasMore() { return _hasMore; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export interface RetryOptions {
|
||||||
|
maxAttempts?: number;
|
||||||
|
baseDelayMs?: number;
|
||||||
|
maxDelayMs?: number;
|
||||||
|
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWithRetry<T>(
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
options: RetryOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
maxAttempts = 3,
|
||||||
|
baseDelayMs = 500,
|
||||||
|
maxDelayMs = 10_000,
|
||||||
|
shouldRetry = () => true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fetcher();
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err;
|
||||||
|
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
|
||||||
|
await new Promise(r => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './fetchWithRetry';
|
||||||
|
export * from './batchRequests';
|
||||||
|
export * from './createPaginatedQuery';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { store, updateSettings } from "../store/state.svelte";
|
import { store, updateSettings } from "@store/state.svelte";
|
||||||
|
|
||||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||||
|
|
||||||
@@ -16,36 +16,25 @@ function basicHeader(user: string, pass: string): Record<string, string> {
|
|||||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAuthenticated(
|
export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
|
||||||
url: string,
|
|
||||||
init: RequestInit,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<Response> {
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
|
||||||
if (mode === "BASIC_AUTH") {
|
if (mode === "BASIC_AUTH") {
|
||||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
...init,
|
...init, signal, credentials: "omit",
|
||||||
signal,
|
headers: { ...(init.headers as Record<string, string> ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) },
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
...(init.headers as Record<string, string> ?? {}),
|
|
||||||
...(user && pass ? basicHeader(user, pass) : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return fetch(url, { ...init, signal, credentials: "omit" });
|
||||||
return fetch(url, { ...init, signal });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||||
method: "POST",
|
method: "POST", credentials: "omit",
|
||||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||||
@@ -59,36 +48,25 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
|
|||||||
const base = getServerBase();
|
const base = getServerBase();
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
const s = store.settings;
|
const s = store.settings;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
if (mode === "BASIC_AUTH") {
|
if (mode === "BASIC_AUTH") {
|
||||||
const user = s.serverAuthUser?.trim() ?? "";
|
const user = s.serverAuthUser?.trim() ?? "";
|
||||||
const pass = s.serverAuthPass?.trim() ?? "";
|
const pass = s.serverAuthPass?.trim() ?? "";
|
||||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
method: "POST",
|
method: "POST", credentials: "omit", headers,
|
||||||
credentials: "include",
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
headers,
|
signal: AbortSignal.timeout(5000),
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
});
|
});
|
||||||
|
if (res.ok) return "ok";
|
||||||
if (res.ok) {
|
|
||||||
return "ok";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
||||||
|
|
||||||
if (/basic/i.test(wwwAuth)) {
|
if (/basic/i.test(wwwAuth)) {
|
||||||
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
||||||
return "auth_required";
|
return "auth_required";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/bearer/i.test(wwwAuth)) {
|
if (/bearer/i.test(wwwAuth)) {
|
||||||
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||||
} else if (mode === "NONE") {
|
} else if (mode === "NONE") {
|
||||||
@@ -96,7 +74,6 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
|
|||||||
}
|
}
|
||||||
return "unsupported_mode";
|
return "unsupported_mode";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "unreachable";
|
return "unreachable";
|
||||||
} catch { return "unreachable"; }
|
} catch { return "unreachable"; }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
function collectAppData(): Record<string, string> {
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key !== null) data[key] = localStorage.getItem(key) ?? "";
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAppData(data: Record<string, string>): void {
|
||||||
|
localStorage.clear();
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAppData(): Promise<void> {
|
||||||
|
const json = JSON.stringify(collectAppData(), null, 2);
|
||||||
|
await invoke("export_app_data", { json });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importAppData(): Promise<void> {
|
||||||
|
const json = await invoke<string>("import_app_data");
|
||||||
|
const data: Record<string, string> = JSON.parse(json);
|
||||||
|
applyAppData(data);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoBackupAppData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(collectAppData());
|
||||||
|
await invoke("auto_backup_app_data", { json });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[moku] auto-backup failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
import { store } from "../store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
|
|
||||||
const cache = new Map<string, string>();
|
const cache = new Map<string, string>();
|
||||||
const inflight = new Map<string, Promise<string>>();
|
const inflight = new Map<string, Promise<string>>();
|
||||||
|
const MAX_CONCURRENT = 6;
|
||||||
const MAX_CONCURRENT = 14;
|
let active = 0;
|
||||||
let active = 0;
|
let drainScheduled = false;
|
||||||
|
|
||||||
interface QueueEntry {
|
interface QueueEntry {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -30,43 +30,54 @@ async function doFetch(url: string): Promise<string> {
|
|||||||
return blobUrl;
|
return blobUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertSorted(entry: QueueEntry) {
|
||||||
|
let lo = 0, hi = queue.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
|
if (queue[mid].priority > entry.priority) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
queue.splice(lo, 0, entry);
|
||||||
|
}
|
||||||
|
|
||||||
function drain() {
|
function drain() {
|
||||||
|
drainScheduled = false;
|
||||||
while (active < MAX_CONCURRENT && queue.length > 0) {
|
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||||
queue.sort((a, b) => b.priority - a.priority);
|
|
||||||
const entry = queue.shift()!;
|
const entry = queue.shift()!;
|
||||||
active++;
|
active++;
|
||||||
doFetch(entry.url)
|
doFetch(entry.url)
|
||||||
.then(entry.resolve, entry.reject)
|
.then(entry.resolve, entry.reject)
|
||||||
.finally(() => {
|
.finally(() => { inflight.delete(entry.url); active--; drain(); });
|
||||||
inflight.delete(entry.url);
|
|
||||||
active--;
|
|
||||||
drain();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleDrain() {
|
||||||
|
if (drainScheduled) return;
|
||||||
|
drainScheduled = true;
|
||||||
|
requestAnimationFrame(drain);
|
||||||
|
}
|
||||||
|
|
||||||
function enqueue(url: string, priority: number): Promise<string> {
|
function enqueue(url: string, priority: number): Promise<string> {
|
||||||
const promise = new Promise<string>((resolve, reject) => {
|
const promise = new Promise<string>((resolve, reject) => { insertSorted({ url, priority, resolve, reject }); });
|
||||||
queue.push({ url, priority, resolve, reject });
|
|
||||||
});
|
|
||||||
inflight.set(url, promise);
|
inflight.set(url, promise);
|
||||||
drain();
|
scheduleDrain();
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlobUrl(url: string, priority = 0): Promise<string> {
|
export function getBlobUrl(url: string, priority = 0): Promise<string> {
|
||||||
if (!url) return Promise.resolve("");
|
if (!url) return Promise.resolve("");
|
||||||
|
|
||||||
const cached = cache.get(url);
|
const cached = cache.get(url);
|
||||||
if (cached) return Promise.resolve(cached);
|
if (cached) return Promise.resolve(cached);
|
||||||
|
|
||||||
const existing = inflight.get(url);
|
const existing = inflight.get(url);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const entry = queue.find(e => e.url === url);
|
const idx = queue.findIndex(e => e.url === url);
|
||||||
if (entry && priority > entry.priority) entry.priority = priority;
|
if (idx !== -1 && priority > queue[idx].priority) {
|
||||||
|
const [entry] = queue.splice(idx, 1);
|
||||||
|
entry.priority = priority;
|
||||||
|
insertSorted(entry);
|
||||||
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
return enqueue(url, priority);
|
return enqueue(url, priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,10 +90,12 @@ export function preloadBlobUrls(urls: string[], basePriority = 0): void {
|
|||||||
|
|
||||||
export function revokeBlobUrl(url: string): void {
|
export function revokeBlobUrl(url: string): void {
|
||||||
const blob = cache.get(url);
|
const blob = cache.get(url);
|
||||||
if (blob) {
|
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
|
||||||
URL.revokeObjectURL(blob);
|
}
|
||||||
cache.delete(url);
|
|
||||||
}
|
export function deprioritizeQueue(): void {
|
||||||
|
for (const entry of queue) entry.priority = 0;
|
||||||
|
queue.sort((a, b) => b.priority - a.priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearBlobCache(): void {
|
export function clearBlobCache(): void {
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './memoryCache';
|
||||||
|
export * from './pageCache';
|
||||||
|
export * from './imageCache';
|
||||||
|
export * from './queryCache';
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { gql, plainThumbUrl } from "@api/client";
|
||||||
|
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
||||||
|
import { dedupeRequest } from "@core/async/batchRequests";
|
||||||
|
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||||
|
|
||||||
|
const pageCache = new Map<number, string[]>();
|
||||||
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
|
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||||
|
const preloadedUrls = new Set<string>();
|
||||||
|
const aspectCache = new Map<string, number>();
|
||||||
|
|
||||||
|
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
||||||
|
if (!useBlob) return Promise.resolve(url);
|
||||||
|
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority));
|
||||||
|
return resolvedUrlCache.get(url)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPages(
|
||||||
|
chapterId: number,
|
||||||
|
useBlob: boolean,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
priorityPage = 0,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const cached = pageCache.get(chapterId);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
|
||||||
|
if (!inflight.has(chapterId)) {
|
||||||
|
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
||||||
|
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||||||
|
.then(d => {
|
||||||
|
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
|
||||||
|
if (useBlob) {
|
||||||
|
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
|
||||||
|
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
|
||||||
|
}
|
||||||
|
pageCache.set(chapterId, urls);
|
||||||
|
return urls;
|
||||||
|
})
|
||||||
|
).finally(() => inflight.delete(chapterId));
|
||||||
|
inflight.set(chapterId, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = inflight.get(chapterId)!;
|
||||||
|
if (!signal) return base;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||||
|
base.then(resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
||||||
|
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
||||||
|
return resolveUrl(url, useBlob).then(src => new Promise(res => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
|
||||||
|
img.onerror = () => res(0.67);
|
||||||
|
img.src = src;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preloadImage(url: string, useBlob: boolean): void {
|
||||||
|
if (preloadedUrls.has(url)) return;
|
||||||
|
preloadedUrls.add(url);
|
||||||
|
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPageCache(chapterId?: number): void {
|
||||||
|
if (chapterId !== undefined) {
|
||||||
|
pageCache.delete(chapterId);
|
||||||
|
inflight.delete(chapterId);
|
||||||
|
} else {
|
||||||
|
pageCache.clear();
|
||||||
|
inflight.clear();
|
||||||
|
resolvedUrlCache.clear();
|
||||||
|
preloadedUrls.clear();
|
||||||
|
aspectCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
interface Entry<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, Entry<unknown>>();
|
||||||
|
const subs = new Map<string, Set<() => void>>();
|
||||||
|
const groups = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||||
|
|
||||||
|
function notify(key: string) { subs.get(key)?.forEach(cb => cb()); }
|
||||||
|
|
||||||
|
function registerGroups(key: string, group?: string | string[]) {
|
||||||
|
if (!group) return;
|
||||||
|
for (const tag of Array.isArray(group) ? group : [group]) {
|
||||||
|
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||||
|
groups.get(tag)!.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cache = {
|
||||||
|
get<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL_MS, group?: string | string[]): Promise<T> {
|
||||||
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
|
||||||
|
const promise = fetcher().catch(err => {
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
}) as Promise<T>;
|
||||||
|
store.set(key, { promise, fetchedAt: Date.now() });
|
||||||
|
registerGroups(key, group);
|
||||||
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
set<T>(key: string, value: T, group?: string | string[]) {
|
||||||
|
store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() });
|
||||||
|
registerGroups(key, group);
|
||||||
|
notify(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
update<T>(key: string, fn: (prev: T) => T) {
|
||||||
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
if (!existing) return;
|
||||||
|
const next = existing.promise.then(fn);
|
||||||
|
store.set(key, { promise: next, fetchedAt: Date.now() });
|
||||||
|
next.then(() => notify(key)).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
has(key: string): boolean { return store.has(key); },
|
||||||
|
|
||||||
|
ageOf(key: string): number | undefined {
|
||||||
|
const e = store.get(key);
|
||||||
|
return e ? Date.now() - e.fetchedAt : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
clear(key: string) { store.delete(key); notify(key); },
|
||||||
|
|
||||||
|
clearGroup(tag: string) {
|
||||||
|
const keys = groups.get(tag);
|
||||||
|
if (!keys) return;
|
||||||
|
for (const key of keys) { store.delete(key); notify(key); }
|
||||||
|
groups.delete(tag);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAll() {
|
||||||
|
const allKeys = [...store.keys()];
|
||||||
|
store.clear(); groups.clear();
|
||||||
|
allKeys.forEach(notify);
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe(key: string, cb: () => void): () => void {
|
||||||
|
if (!subs.has(key)) subs.set(key, new Set());
|
||||||
|
subs.get(key)!.add(cb);
|
||||||
|
return () => subs.get(key)?.delete(cb);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CACHE_GROUPS = {
|
||||||
|
LIBRARY: "g:library",
|
||||||
|
SOURCES: "g:sources",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CACHE_KEYS = {
|
||||||
|
LIBRARY: "library",
|
||||||
|
ALL_MANGA: "all_manga_unfiltered",
|
||||||
|
CATEGORIES: "categories",
|
||||||
|
SEARCH: "search_all_manga",
|
||||||
|
SOURCES: "sources",
|
||||||
|
POPULAR: "popular",
|
||||||
|
GENRE: (genre: string) => `genre:${genre}`,
|
||||||
|
MANGA: (id: number) => `manga:${id}`,
|
||||||
|
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||||
|
|
||||||
|
sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string {
|
||||||
|
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
||||||
|
return `pages:${sourceId}:${type}:${q}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
sourceMangaPage(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", page: number, query?: string | string[]): string {
|
||||||
|
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
||||||
|
return `page:${sourceId}:${type}:${page}:${q}`;
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
|
||||||
|
const p = fetcher().finally(() => inflight.delete(key));
|
||||||
|
inflight.set(key, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _pageSets = new Map<string, Set<number>>();
|
||||||
|
|
||||||
|
export interface PageSet {
|
||||||
|
add(page: number): void;
|
||||||
|
pages(): Set<number>;
|
||||||
|
next(): number;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet {
|
||||||
|
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
|
||||||
|
return {
|
||||||
|
add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); },
|
||||||
|
pages() { return new Set(_pageSets.get(key) ?? []); },
|
||||||
|
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
|
||||||
|
clear() { _pageSets.delete(key); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRECENCY_KEY = "moku-source-frecency";
|
||||||
|
const MAX_FRECENCY_SOURCES = 4;
|
||||||
|
type FrecencyMap = Record<string, number>;
|
||||||
|
|
||||||
|
function loadFrecency(): FrecencyMap {
|
||||||
|
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
|
||||||
|
catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFrecency(map: FrecencyMap) {
|
||||||
|
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordSourceAccess(sourceId: string) {
|
||||||
|
if (!sourceId || sourceId === "0") return;
|
||||||
|
const map = loadFrecency();
|
||||||
|
map[sourceId] = (map[sourceId] ?? 0) + 1;
|
||||||
|
saveFrecency(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
||||||
|
const map = loadFrecency();
|
||||||
|
const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 }));
|
||||||
|
if (withScore.some(x => x.score > 0)) {
|
||||||
|
return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s);
|
||||||
|
}
|
||||||
|
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export interface Keybinds {
|
||||||
|
turnPageRight: string;
|
||||||
|
turnPageLeft: string;
|
||||||
|
firstPage: string;
|
||||||
|
lastPage: string;
|
||||||
|
turnChapterRight: string;
|
||||||
|
turnChapterLeft: string;
|
||||||
|
exitReader: string;
|
||||||
|
toggleReadingDirection: string;
|
||||||
|
togglePageStyle: string;
|
||||||
|
toggleFullscreen: string;
|
||||||
|
openSettings: string;
|
||||||
|
toggleBookmark: string;
|
||||||
|
toggleMarker: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||||
|
turnPageRight: "ArrowRight",
|
||||||
|
turnPageLeft: "ArrowLeft",
|
||||||
|
firstPage: "ctrl+ArrowLeft",
|
||||||
|
lastPage: "ctrl+ArrowRight",
|
||||||
|
turnChapterRight: "]",
|
||||||
|
turnChapterLeft: "[",
|
||||||
|
exitReader: "Backspace",
|
||||||
|
toggleReadingDirection: "d",
|
||||||
|
togglePageStyle: "q",
|
||||||
|
toggleFullscreen: "f",
|
||||||
|
openSettings: "o",
|
||||||
|
toggleBookmark: "m",
|
||||||
|
toggleMarker: "n",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||||
|
turnPageRight: "Turn page right (→)",
|
||||||
|
turnPageLeft: "Turn page left (←)",
|
||||||
|
firstPage: "Jump to first page",
|
||||||
|
lastPage: "Jump to last page",
|
||||||
|
turnChapterRight: "Turn chapter right (→)",
|
||||||
|
turnChapterLeft: "Turn chapter left (←)",
|
||||||
|
exitReader: "Exit reader",
|
||||||
|
toggleReadingDirection: "Toggle reading direction",
|
||||||
|
togglePageStyle: "Toggle page style",
|
||||||
|
toggleFullscreen: "Toggle fullscreen",
|
||||||
|
openSettings: "Open settings",
|
||||||
|
toggleBookmark: "Toggle bookmark",
|
||||||
|
toggleMarker: "Toggle marker",
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
|
||||||
|
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
|
||||||
|
export type { Keybinds } from "./defaultBinds";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
|
export function eventToKeybind(e: KeyboardEvent): string {
|
||||||
|
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (e.ctrlKey) parts.push("ctrl");
|
||||||
|
if (e.altKey) parts.push("alt");
|
||||||
|
if (e.shiftKey) parts.push("shift");
|
||||||
|
if (e.metaKey) parts.push("meta");
|
||||||
|
parts.push(e.key);
|
||||||
|
return parts.join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
|
||||||
|
return eventToKeybind(e) === bind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleFullscreen(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
await win.setFullscreen(!await win.isFullscreen());
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("toggleFullscreen unavailable:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
|
||||||
|
let themeStyleEl: HTMLStyleElement | null = null;
|
||||||
|
|
||||||
|
export function applyTheme() {
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
|
||||||
|
const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
|
||||||
|
|
||||||
|
export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||||
|
if (ms === 0) return;
|
||||||
|
timer = setTimeout(onIdle, ms);
|
||||||
|
onActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
IDLE_EVENTS.forEach(e => window.addEventListener(e, reset, { passive: true }));
|
||||||
|
reset();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
IDLE_EVENTS.forEach(e => window.removeEventListener(e, reset));
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './idle';
|
||||||
|
export * from './zoom';
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
|
||||||
|
let _appliedZoom: number = -1;
|
||||||
|
let _vhRafId: number | null = null;
|
||||||
|
|
||||||
|
export function applyZoom() {
|
||||||
|
const uiZoom = store.settings.uiZoom ?? 1.0;
|
||||||
|
if (uiZoom === _appliedZoom) return;
|
||||||
|
_appliedZoom = uiZoom;
|
||||||
|
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
|
||||||
|
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
|
||||||
|
document.documentElement.style.zoom = `${uiZoom * 100}%`;
|
||||||
|
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
|
||||||
|
_vhRafId = requestAnimationFrame(() => {
|
||||||
|
_vhRafId = null;
|
||||||
|
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleZoomKey(e: KeyboardEvent) {
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
const current = store.settings.uiZoom ?? 1.0;
|
||||||
|
if (e.key === "=" || e.key === "+") { e.preventDefault(); store.settings.uiZoom = Math.min(2.0, Math.round((current + 0.1) * 10) / 10); }
|
||||||
|
else if (e.key === "-") { e.preventDefault(); store.settings.uiZoom = Math.max(0.5, Math.round((current - 0.1) * 10) / 10); }
|
||||||
|
else if (e.key === "0") { e.preventDefault(); store.settings.uiZoom = 1.0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountZoomKey(): () => void {
|
||||||
|
window.addEventListener("keydown", handleZoomKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleZoomKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampZoom(z: number, min: number, max: number): number {
|
||||||
|
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureZoomAnchor(
|
||||||
|
containerEl: HTMLElement | null,
|
||||||
|
style: string,
|
||||||
|
out: { el: HTMLElement | null; offset: number },
|
||||||
|
) {
|
||||||
|
if (!containerEl || style !== "longstrip") return;
|
||||||
|
const containerTop = containerEl.getBoundingClientRect().top;
|
||||||
|
for (const img of containerEl.querySelectorAll<HTMLElement>("img[data-local-page]")) {
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
if (rect.bottom > containerTop) { out.el = img; out.offset = rect.top - containerTop; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreZoomAnchor(
|
||||||
|
containerEl: HTMLElement | null,
|
||||||
|
out: { el: HTMLElement | null; offset: number },
|
||||||
|
) {
|
||||||
|
if (!out.el || !containerEl) return;
|
||||||
|
const el = out.el;
|
||||||
|
out.el = null;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const containerTop = containerEl!.getBoundingClientRect().top;
|
||||||
|
containerEl!.scrollTop += (el.getBoundingClientRect().top - containerTop) - out.offset;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { addToast } from "@store/state.svelte";
|
||||||
|
|
||||||
|
function parse(tag: string): number[] {
|
||||||
|
return tag.replace(/^v/, "").split(".").map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkForUpdateSilently(): Promise<void> {
|
||||||
|
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 latestTag = valid
|
||||||
|
.map(r => r.tag_name)
|
||||||
|
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
||||||
|
.replace(/^v/, "");
|
||||||
|
|
||||||
|
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
|
||||||
|
addToast({
|
||||||
|
kind: "info",
|
||||||
|
title: `Update available — v${latestTag}`,
|
||||||
|
body: "Open Settings → About to install.",
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -1,16 +1,43 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import type { Manga, Source } from "@types";
|
||||||
import type { Source } from "./types";
|
import type { Settings } from "@types";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
// ── Class utility ─────────────────────────────────────────────────────────────
|
||||||
return clsx(inputs);
|
|
||||||
|
export { clsx as cn } from "clsx";
|
||||||
|
|
||||||
|
// ── Time / formatting ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export 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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NSFW genre filtering ──────────────────────────────────────────────────────
|
export function dayLabel(ts: number): string {
|
||||||
|
const d = new Date(ts), now = new Date();
|
||||||
|
if (d.toDateString() === now.toDateString()) return "Today";
|
||||||
|
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
||||||
|
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
||||||
|
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReadTime(m: number): string {
|
||||||
|
if (m < 1) return "< 1 min";
|
||||||
|
if (m < 60) return `${m} min`;
|
||||||
|
const h = Math.floor(m / 60), r = m % 60;
|
||||||
|
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NSFW filtering ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default substrings used when no user-configured list is available.
|
* Default genre substrings used when no user-configured list is available.
|
||||||
* The Settings > Content tab lets users add/remove entries from this list,
|
* Stored as settings.nsfwFilteredTags; editable in Settings > Content.
|
||||||
* which is stored as settings.nsfwFilteredTags.
|
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_NSFW_TAGS = [
|
export const DEFAULT_NSFW_TAGS = [
|
||||||
"adult",
|
"adult",
|
||||||
@@ -27,55 +54,39 @@ export const DEFAULT_NSFW_TAGS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the manga carries at least one genre tag matching any of
|
* Returns true if the manga's genre list contains any of the given substrings.
|
||||||
* the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags
|
* Falls back to DEFAULT_NSFW_TAGS if no tag list is provided.
|
||||||
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
|
|
||||||
*/
|
*/
|
||||||
export function isNsfwManga(
|
export function isNsfwManga(
|
||||||
manga: { genre?: string[] | null },
|
manga: { genre?: string[] | null },
|
||||||
tags: string[] = DEFAULT_NSFW_TAGS,
|
tags: string[] = DEFAULT_NSFW_TAGS,
|
||||||
): boolean {
|
): boolean {
|
||||||
return (manga.genre ?? []).some((g) => {
|
return (manga.genre ?? []).some(g =>
|
||||||
const normalized = g.toLowerCase().trim();
|
tags.some(sub => g.toLowerCase().trim().includes(sub))
|
||||||
return tags.some((sub) => normalized.includes(sub));
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single authoritative NSFW gate used by all views.
|
* Single authoritative NSFW gate used by all views.
|
||||||
|
* Returns true when the manga should be HIDDEN. Priority order:
|
||||||
|
* 1. Source in blockedSourceIds → always hidden, even when showNsfw is on.
|
||||||
|
* 2. showNsfw globally enabled → only blocked sources are hidden.
|
||||||
|
* 3. Source in allowedSourceIds → skip isNsfw flag, but genre tags still apply.
|
||||||
|
* 4. source.isNsfw flag → hidden.
|
||||||
|
* 5. Genre tag match → hidden.
|
||||||
*
|
*
|
||||||
* Returns true when the manga should be HIDDEN. Checks in order:
|
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
|
||||||
* 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(
|
export function shouldHideNsfw(
|
||||||
manga: {
|
manga: Pick<Manga, "genre" | "source">,
|
||||||
genre?: string[] | null;
|
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||||
source?: { id?: string; isNsfw?: boolean } | null;
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
showNsfw: boolean;
|
|
||||||
nsfwFilteredTags: string[];
|
|
||||||
nsfwAllowedSourceIds: string[];
|
|
||||||
nsfwBlockedSourceIds: string[];
|
|
||||||
},
|
|
||||||
): boolean {
|
): boolean {
|
||||||
const srcId = manga.source?.id;
|
const srcId = manga.source?.id;
|
||||||
|
|
||||||
// Explicit block always wins, even when showNsfw is on
|
|
||||||
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
|
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
|
||||||
|
|
||||||
// If NSFW is globally allowed, only explicit blocks apply
|
|
||||||
if (settings.showNsfw) return false;
|
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));
|
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
|
||||||
|
|
||||||
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||||
|
|
||||||
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
||||||
@@ -83,21 +94,11 @@ export function shouldHideNsfw(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate for Source objects — parallel to shouldHideNsfw for manga.
|
* Gate for Source objects — parallel to shouldHideNsfw for manga.
|
||||||
*
|
* Usage: sources.filter(s => !shouldHideSource(s, settings))
|
||||||
* 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(
|
export function shouldHideSource(
|
||||||
source: { id: string; isNsfw: boolean },
|
source: Pick<Source, "id" | "isNsfw">,
|
||||||
settings: {
|
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||||
showNsfw: boolean;
|
|
||||||
nsfwAllowedSourceIds: string[];
|
|
||||||
nsfwBlockedSourceIds: string[];
|
|
||||||
},
|
|
||||||
): boolean {
|
): boolean {
|
||||||
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
|
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
|
||||||
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
|
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
|
||||||
@@ -106,6 +107,11 @@ export function shouldHideSource(
|
|||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates sources by name. When multiple sources share a name,
|
||||||
|
* the preferred language wins; otherwise falls back to alphabetical by lang.
|
||||||
|
* The local source (id "0") is always excluded.
|
||||||
|
*/
|
||||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
const byName = new Map<string, Source[]>();
|
const byName = new Map<string, Source[]>();
|
||||||
for (const src of sources) {
|
for (const src of sources) {
|
||||||
@@ -115,7 +121,7 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
|
|||||||
}
|
}
|
||||||
const picked: Source[] = [];
|
const picked: Source[] = [];
|
||||||
for (const group of byName.values()) {
|
for (const group of byName.values()) {
|
||||||
const preferred = group.find((s) => s.lang === preferredLang);
|
const preferred = group.find(s => s.lang === preferredLang);
|
||||||
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||||
}
|
}
|
||||||
return picked;
|
return picked;
|
||||||
@@ -123,12 +129,7 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
|
|||||||
|
|
||||||
// ── Manga deduplication ───────────────────────────────────────────────────────
|
// ── Manga deduplication ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/** Strips punctuation, articles, and source suffixes for fuzzy title matching. */
|
||||||
* Normalizes a title for fuzzy matching.
|
|
||||||
* Strips punctuation, articles, and common source-specific suffixes so that
|
|
||||||
* "The Greatest Estate Developer" and "Yeokdaegeum Yeongji Seolgyesa" won't
|
|
||||||
* match on title alone — but their identical descriptions will catch them.
|
|
||||||
*/
|
|
||||||
export function normalizeTitle(title: string): string {
|
export function normalizeTitle(title: string): string {
|
||||||
return title
|
return title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -139,76 +140,61 @@ export function normalizeTitle(title: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Strips all non-alphanumeric chars and collapses whitespace. */
|
||||||
* Normalizes a string for fingerprinting — strip all non-alpha, collapse spaces.
|
|
||||||
*/
|
|
||||||
function norm(s: string): string {
|
function norm(s: string): string {
|
||||||
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Description fingerprint — first 200 normalized chars.
|
* First 200 normalized chars of a description — reliable cross-source fingerprint.
|
||||||
* Long enough to reliably identify the same series across sources even when
|
* Returns null if too short (< 60 chars) to be a trustworthy signal.
|
||||||
* translations differ in punctuation or minor wording.
|
|
||||||
* Returns null if too short (< 60 chars) to be a reliable signal.
|
|
||||||
*/
|
*/
|
||||||
function descFingerprint(desc: string | null | undefined): string | null {
|
function descFingerprint(desc: string | null | undefined): string | null {
|
||||||
if (!desc) return null;
|
if (!desc) return null;
|
||||||
const n = norm(desc);
|
const n = norm(desc);
|
||||||
if (n.length < 60) return null;
|
return n.length >= 60 ? n.slice(0, 200) : null;
|
||||||
return n.slice(0, 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Author fingerprint — normalized concatenation of author + artist.
|
* Normalized author + artist concatenation for tie-breaking.
|
||||||
* Used as a tie-breaker / additional signal alongside description.
|
* Returns null if no author info available.
|
||||||
* Two manga with the same authors AND same description are almost certainly
|
|
||||||
* the same series. Returns null if no author info.
|
|
||||||
*/
|
*/
|
||||||
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
||||||
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
||||||
if (!parts.length) return null;
|
return parts.length ? parts.sort().join("|") : null;
|
||||||
return parts.sort().join("|");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduplicates manga by:
|
* Deduplicates manga across sources using title, description, and author signals,
|
||||||
* 1. Normalized title
|
* plus explicit user-defined links (settings.mangaLinks).
|
||||||
* 2. Description fingerprint (first 200 chars)
|
|
||||||
* 3. Author + description together
|
|
||||||
* 4. User-defined links (mangaLinks from store) — explicit "same series" overrides
|
|
||||||
*
|
*
|
||||||
* Pass `links` as `settings.mangaLinks` to honour user-registered pairs.
|
* When two entries match, the better one is kept:
|
||||||
* When two entries match, the PREFERRED one is kept:
|
* - Library membership wins over non-library.
|
||||||
* - Library membership wins
|
* - Otherwise higher downloadCount wins.
|
||||||
* - Otherwise higher downloadCount wins
|
* - Otherwise first occurrence wins.
|
||||||
* - Otherwise first occurrence wins
|
|
||||||
*/
|
*/
|
||||||
export function dedupeMangaByTitle<T extends {
|
export function dedupeMangaByTitle<T extends {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
artist?: string | null;
|
artist?: string | null;
|
||||||
inLibrary?: boolean;
|
inLibrary?: boolean;
|
||||||
downloadCount?: number;
|
downloadCount?: number;
|
||||||
}>(items: T[], links: Record<number, number[]> = {}): T[] {
|
}>(items: T[], links: Record<number, number[]> = {}): T[] {
|
||||||
const byTitle = new Map<string, number>();
|
const byTitle = new Map<string, number>();
|
||||||
const byDesc = new Map<string, number>();
|
const byDesc = new Map<string, number>();
|
||||||
const byAuthorDesc = new Map<string, number>();
|
const byAuthorDesc = new Map<string, number>();
|
||||||
// id → index in out[]
|
|
||||||
const byId = new Map<number, number>();
|
const byId = new Map<number, number>();
|
||||||
const out: T[] = [];
|
const out: T[] = [];
|
||||||
|
|
||||||
for (const m of items) {
|
for (const m of items) {
|
||||||
const tk = normalizeTitle(m.title);
|
const tk = normalizeTitle(m.title);
|
||||||
const dk = descFingerprint(m.description);
|
const dk = descFingerprint(m.description);
|
||||||
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
|
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
|
||||||
|
|
||||||
// Check user-defined links first (explicit override)
|
const linkedIds = links[m.id] ?? [];
|
||||||
const linkedIds = links[m.id] ?? [];
|
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
|
||||||
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
|
|
||||||
|
|
||||||
const existingIdx =
|
const existingIdx =
|
||||||
linkedIdx ??
|
linkedIdx ??
|
||||||
byTitle.get(tk) ??
|
byTitle.get(tk) ??
|
||||||
@@ -217,7 +203,7 @@ export function dedupeMangaByTitle<T extends {
|
|||||||
|
|
||||||
if (existingIdx !== undefined) {
|
if (existingIdx !== undefined) {
|
||||||
const existing = out[existingIdx];
|
const existing = out[existingIdx];
|
||||||
const mBetter =
|
const mBetter =
|
||||||
(m.inLibrary && !existing.inLibrary) ||
|
(m.inLibrary && !existing.inLibrary) ||
|
||||||
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
|
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
|
||||||
|
|
||||||
@@ -243,7 +229,7 @@ export function dedupeMangaByTitle<T extends {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduplicates manga by id only (lossless).
|
* Lossless deduplication by ID only. Preserves first occurrence.
|
||||||
*/
|
*/
|
||||||
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
/* ─────────────────────────────────────────────
|
|
||||||
Moku — Animations
|
|
||||||
───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
@@ -34,25 +30,18 @@
|
|||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
from { background-position: -200% 0; }
|
from { background-position: -200% 0; }
|
||||||
to { background-position: 200% 0; }
|
to { background-position: 200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Utility classes */
|
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
||||||
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
||||||
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
|
||||||
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
||||||
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
||||||
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
||||||
.anim-spin { animation: spin 0.7s linear infinite; }
|
.anim-spin { animation: spin 0.7s linear infinite; }
|
||||||
|
|
||||||
/* Skeleton shimmer */
|
|
||||||
.skeleton {
|
.skeleton {
|
||||||
background: linear-gradient(
|
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
|
||||||
90deg,
|
|
||||||
var(--bg-raised) 25%,
|
|
||||||
var(--bg-overlay) 50%,
|
|
||||||
var(--bg-raised) 75%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.4s ease infinite;
|
animation: shimmer 1.4s ease infinite;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@import "./reset.css";
|
||||||
|
@import "./animations.css";
|
||||||
|
@import "./scrollbars.css";
|
||||||
|
@import "./typography.css";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-void);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol { list-style: none; }
|
||||||
|
|
||||||
|
img, svg { display: block; max-width: 100%; }
|
||||||
|
|
||||||
|
p { margin: 0; }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: transparent; }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
line-height: var(--leading-base);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[data-theme="high-contrast"] {
|
||||||
|
--bg-void: #000000;
|
||||||
|
--bg-base: #080808;
|
||||||
|
--bg-surface: #0d0d0d;
|
||||||
|
--bg-raised: #111111;
|
||||||
|
--bg-overlay: #171717;
|
||||||
|
--bg-subtle: #1e1e1e;
|
||||||
|
|
||||||
|
--border-dim: #252525;
|
||||||
|
--border-base: #303030;
|
||||||
|
--border-strong: #3e3e3e;
|
||||||
|
--border-focus: #5a7a5a;
|
||||||
|
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #e8e6e0;
|
||||||
|
--text-muted: #b0aea8;
|
||||||
|
--text-faint: #6e6c68;
|
||||||
|
--text-disabled: #303030;
|
||||||
|
|
||||||
|
--accent: #7aaa7a;
|
||||||
|
--accent-dim: #2e4a2e;
|
||||||
|
--accent-muted: #1e2e1e;
|
||||||
|
--accent-fg: #bcd8bc;
|
||||||
|
--accent-bright: #9fcf9f;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@import "./high-contrast.css";
|
||||||
|
@import "./light-contrast.css";
|
||||||
|
@import "./light.css";
|
||||||
|
@import "./midnight.css";
|
||||||
|
@import "./warm.css";
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[data-theme="light-contrast"] {
|
||||||
|
--bg-void: #d8d4ce;
|
||||||
|
--bg-base: #e2deda;
|
||||||
|
--bg-surface: #ece8e2;
|
||||||
|
--bg-raised: #f5f2ec;
|
||||||
|
--bg-overlay: #ffffff;
|
||||||
|
--bg-subtle: #e4e0d8;
|
||||||
|
|
||||||
|
--border-dim: #c4c0b8;
|
||||||
|
--border-base: #b0aca4;
|
||||||
|
--border-strong: #989490;
|
||||||
|
--border-focus: #3a5a3a;
|
||||||
|
|
||||||
|
--text-primary: #080806;
|
||||||
|
--text-secondary: #181612;
|
||||||
|
--text-muted: #38342e;
|
||||||
|
--text-faint: #706c64;
|
||||||
|
--text-disabled: #b0aca4;
|
||||||
|
|
||||||
|
--accent: #2a5a2a;
|
||||||
|
--accent-dim: #b0ccb0;
|
||||||
|
--accent-muted: #c8dcc8;
|
||||||
|
--accent-fg: #183818;
|
||||||
|
--accent-bright: #1e4e1e;
|
||||||
|
|
||||||
|
--color-error: #8a1a1a;
|
||||||
|
--color-error-bg: #f8e0e0;
|
||||||
|
--color-read: #e0dcd4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[data-theme="light"] {
|
||||||
|
--bg-void: #e8e6e2;
|
||||||
|
--bg-base: #eeece8;
|
||||||
|
--bg-surface: #f4f2ee;
|
||||||
|
--bg-raised: #faf8f4;
|
||||||
|
--bg-overlay: #ffffff;
|
||||||
|
--bg-subtle: #f0ede8;
|
||||||
|
|
||||||
|
--border-dim: #dedad4;
|
||||||
|
--border-base: #d0ccc6;
|
||||||
|
--border-strong: #bbb6ae;
|
||||||
|
--border-focus: #5a7a5a;
|
||||||
|
|
||||||
|
--text-primary: #1a1916;
|
||||||
|
--text-secondary: #2e2c28;
|
||||||
|
--text-muted: #5a5750;
|
||||||
|
--text-faint: #9a9890;
|
||||||
|
--text-disabled: #c8c4bc;
|
||||||
|
|
||||||
|
--accent: #4a724a;
|
||||||
|
--accent-dim: #c8dcc8;
|
||||||
|
--accent-muted: #deeade;
|
||||||
|
--accent-fg: #2a5a2a;
|
||||||
|
--accent-bright: #3a6a3a;
|
||||||
|
|
||||||
|
--color-error: #a03030;
|
||||||
|
--color-error-bg: #fce8e8;
|
||||||
|
--color-success: #2a6a2a;
|
||||||
|
--color-info: #2a4a7a;
|
||||||
|
--color-info-bg: #e8eef8;
|
||||||
|
--color-read: #e8e4dc;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[data-theme="midnight"] {
|
||||||
|
--bg-void: #050810;
|
||||||
|
--bg-base: #080c18;
|
||||||
|
--bg-surface: #0c1020;
|
||||||
|
--bg-raised: #101428;
|
||||||
|
--bg-overlay: #151a30;
|
||||||
|
--bg-subtle: #1a2038;
|
||||||
|
|
||||||
|
--border-dim: #1a2035;
|
||||||
|
--border-base: #222840;
|
||||||
|
--border-strong: #2c3450;
|
||||||
|
--border-focus: #4a5c8a;
|
||||||
|
|
||||||
|
--text-primary: #eeeef8;
|
||||||
|
--text-secondary: #c0c4d8;
|
||||||
|
--text-muted: #808498;
|
||||||
|
--text-faint: #404860;
|
||||||
|
--text-disabled: #202840;
|
||||||
|
|
||||||
|
--accent: #6a7ab8;
|
||||||
|
--accent-dim: #252d50;
|
||||||
|
--accent-muted: #181e38;
|
||||||
|
--accent-fg: #a8b4e8;
|
||||||
|
--accent-bright: #8896d0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[data-theme="warm"] {
|
||||||
|
--bg-void: #0c0a06;
|
||||||
|
--bg-base: #100e08;
|
||||||
|
--bg-surface: #16130c;
|
||||||
|
--bg-raised: #1c1810;
|
||||||
|
--bg-overlay: #221e14;
|
||||||
|
--bg-subtle: #28241a;
|
||||||
|
|
||||||
|
--border-dim: #201c10;
|
||||||
|
--border-base: #2c2818;
|
||||||
|
--border-strong: #3a3420;
|
||||||
|
--border-focus: #6a5a30;
|
||||||
|
|
||||||
|
--text-primary: #f5f0e0;
|
||||||
|
--text-secondary: #d8d0b0;
|
||||||
|
--text-muted: #988c60;
|
||||||
|
--text-faint: #584e30;
|
||||||
|
--text-disabled: #302a18;
|
||||||
|
|
||||||
|
--accent: #c0902a;
|
||||||
|
--accent-dim: #3a2c10;
|
||||||
|
--accent-muted: #261e0c;
|
||||||
|
--accent-fg: #e0b860;
|
||||||
|
--accent-bright: #d0a040;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
:root {
|
||||||
|
--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;
|
||||||
|
--color-read: #2e2e2c;
|
||||||
|
|
||||||
|
--dot-active: var(--accent);
|
||||||
|
--dot-inactive: var(--text-faint);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@import "./colors.css";
|
||||||
|
@import "./typography.css";
|
||||||
|
@import "./spacing.css";
|
||||||
|
@import "./radius.css";
|
||||||
|
@import "./motion.css";
|
||||||
|
@import "./shadows.css";
|
||||||
|
@import "./zindex.css";
|
||||||
|
@import "../themes/index.css";
|
||||||