Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c573c54318 | |||
| ff5fcc4fc0 | |||
| 64f63ceaa2 | |||
| 6d835914ef | |||
| 10f5936dbd | |||
| 5ddbfdbd6d | |||
| 0ff148f720 | |||
| d98ca76036 | |||
| 35650481b0 | |||
| 8b16537c35 | |||
| 96639d2152 | |||
| 1c135a79ca | |||
| 6c11a9d53e | |||
| 5a2f88b806 | |||
| 75430305e6 | |||
| ea76b5fc26 | |||
| d5d9ff8b6e | |||
| 7c9182eb4b | |||
| 4d6ebe8804 | |||
| 49562c3f76 | |||
| 4a299f60ac | |||
| de397f2462 | |||
| af29cffdff | |||
| f840ae6413 | |||
| 6b8d4fc05f | |||
| 15079f7755 | |||
| 1a08d2415f | |||
| 7917491389 | |||
| 0b6e9fbbbb |
@@ -99,16 +99,16 @@ exec /usr/lib/moku/jre/bin/java \
|
||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||
EOF
|
||||
|
||||
install -Dm644 packaging/dev.moku.app.desktop \
|
||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \
|
||||
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop"
|
||||
install -Dm644 src-tauri/icons/32x32.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.app.png"
|
||||
install -Dm644 src-tauri/icons/128x128.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.app.png"
|
||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png"
|
||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
||||
<img src="docs/screenshots/Moku-Discover.png" width="49%" alt="Discover" />
|
||||
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="TagSearch" />
|
||||
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
||||
@@ -37,13 +37,18 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
||||
## Features
|
||||
|
||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, A–Z, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
|
||||
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
||||
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
||||
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||
- **Auto-updates** — in-app update checker with silent background notifications
|
||||
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -10,14 +10,13 @@ Minor Revisions:
|
||||
- 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)
|
||||
- Integrate Download Directory Changes (Settings)
|
||||
|
||||
|
||||
|
||||
Priority Bugs:
|
||||
- Cache ALL Cover Pictures & Details for Manga in Library
|
||||
- Fix Library Build not Updating
|
||||
- Check Auth System (Only Supports Basic-Auth)
|
||||
- Loading Buffer for Pictures (Due to Auth Lag)
|
||||
|
||||
|
||||
General/Misc Bugs:
|
||||
@@ -31,13 +30,16 @@ General/Misc Bugs:
|
||||
In-Progress:
|
||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||
|
||||
- Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
|
||||
- Working on 3D Display Cards
|
||||
- Chapter refresh Notification Looks bad (Series Detail)
|
||||
|
||||
- Fix Discover Workout
|
||||
- Fix CSS on Saved State for Search
|
||||
- Fix State & Cache names (Mapped to Discover hence needs Renaming)
|
||||
- Completely Remove Discover
|
||||
|
||||
- Add Small QOL Animations where Appropriate
|
||||
|
||||
|
||||
|
||||
Testing:
|
||||
|
||||
- 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, ... }:
|
||||
let
|
||||
version = "0.7.1";
|
||||
version = "0.8.0";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
@@ -177,7 +177,7 @@ EOF
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
MANIFEST="$REPO/dev.moku.app.yml"
|
||||
MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
|
||||
|
||||
echo "── Bumping versions ──"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||
@@ -226,7 +226,7 @@ EOF
|
||||
--force-clean \
|
||||
"$REPO/build-dir" \
|
||||
"$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"
|
||||
echo "moku.flatpak created"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
app-id: dev.moku.app
|
||||
app-id: io.github.Youwes09.Moku
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '48'
|
||||
sdk: org.gnome.Sdk
|
||||
@@ -9,16 +9,22 @@ separate-locales: false
|
||||
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=x11
|
||||
- --socket=fallback-x11
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
- --share=network
|
||||
- --socket=session-bus
|
||||
- --socket=system-bus
|
||||
- --filesystem=home
|
||||
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --talk-name=org.freedesktop.portal.Desktop
|
||||
- --talk-name=org.freedesktop.portal.FileTransfer
|
||||
|
||||
- --talk-name=org.kde.StatusNotifierWatcher
|
||||
- --talk-name=com.canonical.AppMenu.Registrar
|
||||
- --talk-name=com.canonical.indicator.application
|
||||
|
||||
- --filesystem=xdg-run/discord-ipc-0:ro
|
||||
- --filesystem=xdg-data/moku:create
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-download
|
||||
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/rust-stable/bin
|
||||
@@ -33,13 +39,10 @@ modules:
|
||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
|
||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
|
||||
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
|
||||
dest-filename: jdk.tar.gz
|
||||
|
||||
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
|
||||
# exits just that thread instead of killing the whole JVM. Official Suwayomi
|
||||
# fix for headless environments. Source inlined to avoid upstream drift.
|
||||
- name: catch-abort
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
@@ -120,7 +123,6 @@ modules:
|
||||
fi
|
||||
|
||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
||||
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
|
||||
sed -i \
|
||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||
@@ -138,8 +140,6 @@ modules:
|
||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||
|
||||
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
|
||||
# that thread instead of crashing the whole JVM process.
|
||||
export LD_PRELOAD="/app/lib/catch_abort.so"
|
||||
|
||||
exec /app/jre/bin/java \
|
||||
@@ -171,17 +171,19 @@ modules:
|
||||
- tar -xzf frontend-dist.tar.gz
|
||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
|
||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
|
||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
|
||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
|
||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
||||
- install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop
|
||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png
|
||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.png
|
||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.png
|
||||
- install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml
|
||||
sources:
|
||||
- type: dir
|
||||
path: .
|
||||
- type: git
|
||||
url: https://github.com/Youwes09/Moku.git
|
||||
tag: v0.8.0
|
||||
commit: ff5fcc4fc0dd97e187fac15480406993bc4231da
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: d3ebde4d39e3de61420b78a9506df1a5c77c14d705e42662a45a2179bc96030e
|
||||
sha256: f21034da4b8da42d8084978b60e429162aabb28808fa019ffb786e877c4ae95b
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
@@ -1,4 +1,10 @@
|
||||
[
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://github.com/youwes09/tauri-plugin-discord-rpc",
|
||||
"commit": "d2fd312945d0573153e0e7e2d2dfb131acecc52c",
|
||||
"dest": "flatpak-cargo/git/tauri-plugin-discord-rpc-d2fd312"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -210,14 +216,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/bitflags/bitflags-2.11.0.crate",
|
||||
"sha256": "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af",
|
||||
"dest": "cargo/vendor/bitflags-2.11.0"
|
||||
"url": "https://static.crates.io/crates/bitflags/bitflags-2.11.1.crate",
|
||||
"sha256": "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3",
|
||||
"dest": "cargo/vendor/bitflags-2.11.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/bitflags-2.11.0",
|
||||
"contents": "{\"package\": \"c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/bitflags-2.11.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -405,14 +411,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/cc/cc-1.2.58.crate",
|
||||
"sha256": "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1",
|
||||
"dest": "cargo/vendor/cc-1.2.58"
|
||||
"url": "https://static.crates.io/crates/cc/cc-1.2.60.crate",
|
||||
"sha256": "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20",
|
||||
"dest": "cargo/vendor/cc-1.2.60"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/cc-1.2.58",
|
||||
"contents": "{\"package\": \"43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/cc-1.2.60",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -532,19 +538,6 @@
|
||||
"dest": "cargo/vendor/cookie-0.18.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/cookie_store/cookie_store-0.21.1.crate",
|
||||
"sha256": "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9",
|
||||
"dest": "cargo/vendor/cookie_store-0.21.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/cookie_store-0.21.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -948,6 +941,19 @@
|
||||
"dest": "cargo/vendor/dirs-sys-0.5.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/discord-rich-presence/discord-rich-presence-1.1.0.crate",
|
||||
"sha256": "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494",
|
||||
"dest": "cargo/vendor/discord-rich-presence-1.1.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/discord-rich-presence-1.1.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -1185,14 +1191,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/fastrand/fastrand-2.3.0.crate",
|
||||
"sha256": "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be",
|
||||
"dest": "cargo/vendor/fastrand-2.3.0"
|
||||
"url": "https://static.crates.io/crates/fastrand/fastrand-2.4.1.crate",
|
||||
"sha256": "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6",
|
||||
"dest": "cargo/vendor/fastrand-2.4.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/fastrand-2.3.0",
|
||||
"contents": "{\"package\": \"9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/fastrand-2.4.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -1299,6 +1305,19 @@
|
||||
"dest": "cargo/vendor/foldhash-0.2.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/foreign-types/foreign-types-0.3.2.crate",
|
||||
"sha256": "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1",
|
||||
"dest": "cargo/vendor/foreign-types-0.3.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/foreign-types-0.3.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -1325,6 +1344,19 @@
|
||||
"dest": "cargo/vendor/foreign-types-macros-0.2.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/foreign-types-shared/foreign-types-shared-0.1.1.crate",
|
||||
"sha256": "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b",
|
||||
"dest": "cargo/vendor/foreign-types-shared-0.1.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/foreign-types-shared-0.1.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -1822,14 +1854,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/hashbrown/hashbrown-0.16.1.crate",
|
||||
"sha256": "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100",
|
||||
"dest": "cargo/vendor/hashbrown-0.16.1"
|
||||
"url": "https://static.crates.io/crates/hashbrown/hashbrown-0.17.0.crate",
|
||||
"sha256": "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51",
|
||||
"dest": "cargo/vendor/hashbrown-0.17.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/hashbrown-0.16.1",
|
||||
"contents": "{\"package\": \"4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/hashbrown-0.17.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -1965,14 +1997,27 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/hyper-rustls/hyper-rustls-0.27.7.crate",
|
||||
"sha256": "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58",
|
||||
"dest": "cargo/vendor/hyper-rustls-0.27.7"
|
||||
"url": "https://static.crates.io/crates/hyper-rustls/hyper-rustls-0.27.9.crate",
|
||||
"sha256": "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f",
|
||||
"dest": "cargo/vendor/hyper-rustls-0.27.9"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/hyper-rustls-0.27.7",
|
||||
"contents": "{\"package\": \"33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/hyper-rustls-0.27.9",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/hyper-tls/hyper-tls-0.6.0.crate",
|
||||
"sha256": "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0",
|
||||
"dest": "cargo/vendor/hyper-tls-0.6.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/hyper-tls-0.6.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2186,14 +2231,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/indexmap/indexmap-2.13.1.crate",
|
||||
"sha256": "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff",
|
||||
"dest": "cargo/vendor/indexmap-2.13.1"
|
||||
"url": "https://static.crates.io/crates/indexmap/indexmap-2.14.0.crate",
|
||||
"sha256": "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9",
|
||||
"dest": "cargo/vendor/indexmap-2.14.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/indexmap-2.13.1",
|
||||
"contents": "{\"package\": \"d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/indexmap-2.14.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2355,14 +2400,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.94.crate",
|
||||
"sha256": "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9",
|
||||
"dest": "cargo/vendor/js-sys-0.3.94"
|
||||
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.95.crate",
|
||||
"sha256": "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca",
|
||||
"dest": "cargo/vendor/js-sys-0.3.95"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/js-sys-0.3.94",
|
||||
"contents": "{\"package\": \"2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/js-sys-0.3.95",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2459,14 +2504,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/libc/libc-0.2.184.crate",
|
||||
"sha256": "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af",
|
||||
"dest": "cargo/vendor/libc-0.2.184"
|
||||
"url": "https://static.crates.io/crates/libc/libc-0.2.185.crate",
|
||||
"sha256": "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f",
|
||||
"dest": "cargo/vendor/libc-0.2.185"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/libc-0.2.184",
|
||||
"contents": "{\"package\": \"52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/libc-0.2.185",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2485,14 +2530,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/libredox/libredox-0.1.15.crate",
|
||||
"sha256": "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08",
|
||||
"dest": "cargo/vendor/libredox-0.1.15"
|
||||
"url": "https://static.crates.io/crates/libredox/libredox-0.1.16.crate",
|
||||
"sha256": "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c",
|
||||
"dest": "cargo/vendor/libredox-0.1.16"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/libredox-0.1.15",
|
||||
"contents": "{\"package\": \"e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/libredox-0.1.16",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2729,6 +2774,19 @@
|
||||
"dest": "cargo/vendor/muda-0.17.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/native-tls/native-tls-0.2.18.crate",
|
||||
"sha256": "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2",
|
||||
"dest": "cargo/vendor/native-tls-0.2.18"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/native-tls-0.2.18",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3028,6 +3086,19 @@
|
||||
"dest": "cargo/vendor/objc2-foundation-0.3.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/objc2-io-kit/objc2-io-kit-0.3.2.crate",
|
||||
"sha256": "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15",
|
||||
"dest": "cargo/vendor/objc2-io-kit-0.3.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/objc2-io-kit-0.3.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3132,6 +3203,32 @@
|
||||
"dest": "cargo/vendor/open-5.3.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/openssl/openssl-0.10.77.crate",
|
||||
"sha256": "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f",
|
||||
"dest": "cargo/vendor/openssl-0.10.77"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-0.10.77",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/openssl-macros/openssl-macros-0.1.1.crate",
|
||||
"sha256": "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c",
|
||||
"dest": "cargo/vendor/openssl-macros-0.1.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-macros-0.1.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3145,6 +3242,19 @@
|
||||
"dest": "cargo/vendor/openssl-probe-0.2.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.113.crate",
|
||||
"sha256": "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644",
|
||||
"dest": "cargo/vendor/openssl-sys-0.9.113"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-sys-0.9.113",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3525,14 +3635,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/pkg-config/pkg-config-0.3.32.crate",
|
||||
"sha256": "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c",
|
||||
"dest": "cargo/vendor/pkg-config-0.3.32"
|
||||
"url": "https://static.crates.io/crates/pkg-config/pkg-config-0.3.33.crate",
|
||||
"sha256": "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e",
|
||||
"dest": "cargo/vendor/pkg-config-0.3.33"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/pkg-config-0.3.32",
|
||||
"contents": "{\"package\": \"19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/pkg-config-0.3.33",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -3876,14 +3986,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/rand/rand-0.9.2.crate",
|
||||
"sha256": "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1",
|
||||
"dest": "cargo/vendor/rand-0.9.2"
|
||||
"url": "https://static.crates.io/crates/rand/rand-0.9.4.crate",
|
||||
"sha256": "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea",
|
||||
"dest": "cargo/vendor/rand-0.9.4"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rand-0.9.2",
|
||||
"contents": "{\"package\": \"44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rand-0.9.4",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4006,14 +4116,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/rayon/rayon-1.11.0.crate",
|
||||
"sha256": "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f",
|
||||
"dest": "cargo/vendor/rayon-1.11.0"
|
||||
"url": "https://static.crates.io/crates/rayon/rayon-1.12.0.crate",
|
||||
"sha256": "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d",
|
||||
"dest": "cargo/vendor/rayon-1.12.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rayon-1.11.0",
|
||||
"contents": "{\"package\": \"fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rayon-1.12.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4045,14 +4155,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/redox_syscall/redox_syscall-0.7.3.crate",
|
||||
"sha256": "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16",
|
||||
"dest": "cargo/vendor/redox_syscall-0.7.3"
|
||||
"url": "https://static.crates.io/crates/redox_syscall/redox_syscall-0.7.4.crate",
|
||||
"sha256": "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a",
|
||||
"dest": "cargo/vendor/redox_syscall-0.7.4"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/redox_syscall-0.7.3",
|
||||
"contents": "{\"package\": \"f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/redox_syscall-0.7.4",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4172,6 +4282,19 @@
|
||||
"dest": "cargo/vendor/reqwest-0.13.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/rfd/rfd-0.16.0.crate",
|
||||
"sha256": "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672",
|
||||
"dest": "cargo/vendor/rfd-0.16.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rfd-0.16.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -4185,19 +4308,6 @@
|
||||
"dest": "cargo/vendor/ring-0.17.14",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/rpcdiscord/rpcdiscord-0.2.6.crate",
|
||||
"sha256": "71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d",
|
||||
"dest": "cargo/vendor/rpcdiscord-0.2.6"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rpcdiscord-0.2.6",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -4240,14 +4350,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/rustls/rustls-0.23.37.crate",
|
||||
"sha256": "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4",
|
||||
"dest": "cargo/vendor/rustls-0.23.37"
|
||||
"url": "https://static.crates.io/crates/rustls/rustls-0.23.38.crate",
|
||||
"sha256": "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21",
|
||||
"dest": "cargo/vendor/rustls-0.23.38"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rustls-0.23.37",
|
||||
"contents": "{\"package\": \"69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rustls-0.23.38",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4305,14 +4415,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.10.crate",
|
||||
"sha256": "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef",
|
||||
"dest": "cargo/vendor/rustls-webpki-0.103.10"
|
||||
"url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.12.crate",
|
||||
"sha256": "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06",
|
||||
"dest": "cargo/vendor/rustls-webpki-0.103.12"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rustls-webpki-0.103.10",
|
||||
"contents": "{\"package\": \"8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rustls-webpki-0.103.12",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4487,14 +4597,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/semver/semver-1.0.27.crate",
|
||||
"sha256": "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2",
|
||||
"dest": "cargo/vendor/semver-1.0.27"
|
||||
"url": "https://static.crates.io/crates/semver/semver-1.0.28.crate",
|
||||
"sha256": "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd",
|
||||
"dest": "cargo/vendor/semver-1.0.28"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/semver-1.0.27",
|
||||
"contents": "{\"package\": \"8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/semver-1.0.28",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5082,6 +5192,19 @@
|
||||
"dest": "cargo/vendor/sysinfo-0.32.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/sysinfo/sysinfo-0.36.1.crate",
|
||||
"sha256": "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d",
|
||||
"dest": "cargo/vendor/sysinfo-0.36.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/sysinfo-0.36.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -5241,40 +5364,58 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-drpc/tauri-plugin-drpc-0.1.6.crate",
|
||||
"sha256": "7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a",
|
||||
"dest": "cargo/vendor/tauri-plugin-drpc-0.1.6"
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-dialog/tauri-plugin-dialog-2.7.0.crate",
|
||||
"sha256": "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809",
|
||||
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-drpc-0.1.6",
|
||||
"contents": "{\"package\": \"a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"commands": [
|
||||
"cp -r --reflink=auto \"flatpak-cargo/git/tauri-plugin-discord-rpc-d2fd312/.\" \"cargo/vendor/tauri-plugin-discord-rpc\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "[package]\nname = \"tauri-plugin-discord-rpc\"\nversion = \"0.1.0\"\nauthors = [\"You\"]\ndescription = \"A Tauri plugin for Discord Rich Presence\"\nedition = \"2021\"\nrust-version = \"1.77.2\"\nexclude = [\"/examples\", \"/dist-js\", \"/guest-js\", \"/node_modules\"]\nlinks = \"tauri-plugin-discord-rpc\"\n\n[dependencies]\nserde_json = \"1.0\"\nthiserror = \"2\"\ndiscord-rich-presence = \"1.1.0\"\nlog = \"0.4\"\nlibc = \"0.2.184\"\n\n[dependencies.tauri]\nversion = \"2.10.3\"\n\n[dependencies.serde]\nversion = \"1.0\"\nfeatures = [\"derive\"]\n\n[dependencies.tokio]\nversion = \"1\"\nfeatures = [\"sync\", \"time\", \"rt\", \"macros\"]\n\n[dependencies.sysinfo]\nversion = \"0.36.1\"\ndefault-features = false\nfeatures = [\"system\"]\n\n[build-dependencies.tauri-plugin]\nversion = \"2.5.4\"\nfeatures = [\"build\"]\n",
|
||||
"dest": "cargo/vendor/tauri-plugin-discord-rpc",
|
||||
"dest-filename": "Cargo.toml"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": null, \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-discord-rpc",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-fs/tauri-plugin-fs-2.4.5.crate",
|
||||
"sha256": "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804",
|
||||
"dest": "cargo/vendor/tauri-plugin-fs-2.4.5"
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-fs/tauri-plugin-fs-2.5.0.crate",
|
||||
"sha256": "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8",
|
||||
"dest": "cargo/vendor/tauri-plugin-fs-2.5.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-fs-2.4.5",
|
||||
"contents": "{\"package\": \"36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-fs-2.5.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-http/tauri-plugin-http-2.5.7.crate",
|
||||
"sha256": "d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc",
|
||||
"dest": "cargo/vendor/tauri-plugin-http-2.5.7"
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-http/tauri-plugin-http-2.5.8.crate",
|
||||
"sha256": "cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d",
|
||||
"dest": "cargo/vendor/tauri-plugin-http-2.5.8"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-http-2.5.7",
|
||||
"contents": "{\"package\": \"cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-http-2.5.8",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5319,14 +5460,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-updater/tauri-plugin-updater-2.10.0.crate",
|
||||
"sha256": "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61",
|
||||
"dest": "cargo/vendor/tauri-plugin-updater-2.10.0"
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-updater/tauri-plugin-updater-2.10.1.crate",
|
||||
"sha256": "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af",
|
||||
"dest": "cargo/vendor/tauri-plugin-updater-2.10.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-updater-2.10.0",
|
||||
"contents": "{\"package\": \"806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-updater-2.10.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5553,27 +5694,40 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tokio/tokio-1.50.0.crate",
|
||||
"sha256": "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d",
|
||||
"dest": "cargo/vendor/tokio-1.50.0"
|
||||
"url": "https://static.crates.io/crates/tokio/tokio-1.51.1.crate",
|
||||
"sha256": "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c",
|
||||
"dest": "cargo/vendor/tokio-1.51.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tokio-1.50.0",
|
||||
"contents": "{\"package\": \"f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tokio-1.51.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tokio-macros/tokio-macros-2.6.1.crate",
|
||||
"sha256": "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c",
|
||||
"dest": "cargo/vendor/tokio-macros-2.6.1"
|
||||
"url": "https://static.crates.io/crates/tokio-macros/tokio-macros-2.7.0.crate",
|
||||
"sha256": "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496",
|
||||
"dest": "cargo/vendor/tokio-macros-2.7.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tokio-macros-2.6.1",
|
||||
"contents": "{\"package\": \"385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tokio-macros-2.7.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tokio-native-tls/tokio-native-tls-0.3.1.crate",
|
||||
"sha256": "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2",
|
||||
"dest": "cargo/vendor/tokio-native-tls-0.3.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tokio-native-tls-0.3.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5696,14 +5850,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.10+spec-1.1.0.crate",
|
||||
"sha256": "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.10+spec-1.1.0"
|
||||
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.11+spec-1.1.0.crate",
|
||||
"sha256": "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.11+spec-1.1.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.10+spec-1.1.0",
|
||||
"contents": "{\"package\": \"0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.11+spec-1.1.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5992,6 +6146,19 @@
|
||||
"dest": "cargo/vendor/url-2.5.8",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/urlencoding/urlencoding-2.1.3.crate",
|
||||
"sha256": "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da",
|
||||
"dest": "cargo/vendor/urlencoding-2.1.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/urlencoding-2.1.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -6057,6 +6224,19 @@
|
||||
"dest": "cargo/vendor/uuid-1.23.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/vcpkg/vcpkg-0.2.15.crate",
|
||||
"sha256": "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426",
|
||||
"dest": "cargo/vendor/vcpkg-0.2.15"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/vcpkg-0.2.15",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -6190,66 +6370,66 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.117.crate",
|
||||
"sha256": "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.117"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.118.crate",
|
||||
"sha256": "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.118"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.117",
|
||||
"contents": "{\"package\": \"0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.118",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.67.crate",
|
||||
"sha256": "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.67"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.68.crate",
|
||||
"sha256": "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.68"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.67",
|
||||
"contents": "{\"package\": \"f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.68",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.117.crate",
|
||||
"sha256": "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.117"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.118.crate",
|
||||
"sha256": "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.118"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.117",
|
||||
"contents": "{\"package\": \"eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.118",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.117.crate",
|
||||
"sha256": "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.118.crate",
|
||||
"sha256": "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.118"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117",
|
||||
"contents": "{\"package\": \"9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.118",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.117.crate",
|
||||
"sha256": "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.117"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.118.crate",
|
||||
"sha256": "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.118"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.117",
|
||||
"contents": "{\"package\": \"5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.118",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -6307,14 +6487,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.94.crate",
|
||||
"sha256": "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a",
|
||||
"dest": "cargo/vendor/web-sys-0.3.94"
|
||||
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.95.crate",
|
||||
"sha256": "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d",
|
||||
"dest": "cargo/vendor/web-sys-0.3.95"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/web-sys-0.3.94",
|
||||
"contents": "{\"package\": \"4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/web-sys-0.3.95",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -7658,7 +7838,7 @@
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "[source.vendored-sources]\ndirectory = \"cargo/vendor\"\n\n[source.crates-io]\nreplace-with = \"vendored-sources\"\n",
|
||||
"contents": "[source.vendored-sources]\ndirectory = \"cargo/vendor\"\n\n[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n[source.\"https://github.com/youwes09/tauri-plugin-discord-rpc\"]\ngit = \"https://github.com/youwes09/tauri-plugin-discord-rpc\"\nreplace-with = \"vendored-sources\"\n",
|
||||
"dest": "cargo",
|
||||
"dest-filename": "config"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Comment=Manga reader powered by Suwayomi
|
||||
Exec=moku
|
||||
Icon=dev.moku.app
|
||||
Icon=io.github.Youwes09.Moku
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Graphics;Viewer;
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>io.github.Youwes09.Moku</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<name>Moku</name>
|
||||
<summary>Manga reader powered by Suwayomi</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||
providing a clean native interface for browsing, reading, and managing your
|
||||
manga library across hundreds of sources.
|
||||
</p>
|
||||
<p>
|
||||
Features include library management, chapter tracking, extension support,
|
||||
reading history, notifications, and Discord Rich Presence integration.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/Youwes09/Moku</url>
|
||||
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||
<caption>Home screen showing your manga library</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||
<caption>Built-in manga reader</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||
<caption>Discover new manga across hundreds of sources</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||
<caption>Download manager</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||
<caption>Settings</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<provides>
|
||||
<binary>moku</binary>
|
||||
</provides>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="0.8.0" date="2025-04-01">
|
||||
<description>
|
||||
<p>Latest release with improved stability and UI refinements.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.4.0" date="2025-03-22">
|
||||
<description>
|
||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -126,9 +126,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -205,7 +205,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
@@ -268,9 +268,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.59"
|
||||
version = "1.2.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -404,7 +404,7 @@ version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
@@ -417,7 +417,7 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
@@ -678,7 +678,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -702,7 +702,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -861,14 +861,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.0"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
@@ -1284,7 +1284,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -1406,7 +1406,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -1430,9 +1430,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -1536,15 +1536,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
@@ -1754,12 +1753,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.1"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -1883,9 +1882,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.94"
|
||||
version = "0.3.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -1921,7 +1920,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
@@ -1934,7 +1933,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
|
||||
dependencies = [
|
||||
"cssparser 0.29.6",
|
||||
"html5ever 0.29.1",
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"selectors 0.24.0",
|
||||
]
|
||||
|
||||
@@ -1970,9 +1969,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -1986,14 +1985,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2133,7 +2132,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moku"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2142,6 +2141,7 @@ dependencies = [
|
||||
"sysinfo 0.32.1",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-discord-rpc",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-os",
|
||||
@@ -2197,7 +2197,7 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
@@ -2233,7 +2233,7 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -2307,7 +2307,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2320,7 +2320,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2341,7 +2341,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
@@ -2352,7 +2352,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2385,7 +2385,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -2412,7 +2412,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -2435,7 +2435,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2446,7 +2446,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
@@ -2458,7 +2458,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
@@ -2470,7 +2470,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
@@ -2501,7 +2501,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -2529,11 +2529,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.76"
|
||||
version = "0.10.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
|
||||
checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
@@ -2561,9 +2561,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.112"
|
||||
version = "0.9.113"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||
checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -2600,7 +2600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2872,9 +2872,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
@@ -2889,7 +2889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
@@ -2974,7 +2974,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.10+spec-1.1.0",
|
||||
"toml_edit 0.25.11+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3070,7 +3070,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -3144,9 +3144,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
@@ -3235,9 +3235,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
@@ -3259,16 +3259,16 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.3"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3429,6 +3429,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3464,18 +3488,18 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
@@ -3525,7 +3549,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3536,9 +3560,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3638,7 +3662,7 @@ version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
@@ -3679,7 +3703,7 @@ version = "0.36.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cssparser 0.36.0",
|
||||
"derive_more 2.1.1",
|
||||
"log",
|
||||
@@ -3819,7 +3843,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.1",
|
||||
"serde_core",
|
||||
@@ -3977,7 +4001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4191,7 +4215,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
@@ -4225,7 +4249,7 @@ version = "0.34.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
@@ -4416,6 +4440,24 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-discord-rpc"
|
||||
version = "0.1.0"
|
||||
@@ -4673,7 +4715,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4795,9 +4837,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.51.0"
|
||||
version = "1.51.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
|
||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -4870,7 +4912,7 @@ version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
@@ -4912,7 +4954,7 @@ version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 0.6.3",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
@@ -4923,7 +4965,7 @@ version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
||||
dependencies = [
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.3",
|
||||
@@ -4932,11 +4974,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.10+spec-1.1.0"
|
||||
version = "0.25.11+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
|
||||
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
||||
dependencies = [
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.1",
|
||||
@@ -4978,7 +5020,7 @@ version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -5279,9 +5321,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.117"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -5292,9 +5334,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.67"
|
||||
version = "0.4.68"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
|
||||
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -5302,9 +5344,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.117"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -5312,9 +5354,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.117"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -5325,9 +5367,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.117"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -5349,7 +5391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
@@ -5373,17 +5415,17 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.94"
|
||||
version = "0.3.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
|
||||
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -5531,7 +5573,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6135,7 +6177,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"prettyplease",
|
||||
"syn 2.0.117",
|
||||
"wasm-metadata",
|
||||
@@ -6165,8 +6207,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.0",
|
||||
"indexmap 2.13.1",
|
||||
"bitflags 2.11.1",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
@@ -6185,7 +6227,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -6387,7 +6429,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.1",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -20,6 +20,7 @@ tauri-plugin-shell = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -272,7 +272,7 @@ fn suwayomi_data_dir() -> PathBuf {
|
||||
{
|
||||
dirs::data_dir()
|
||||
.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")))]
|
||||
{
|
||||
@@ -327,6 +327,22 @@ fn resolve_server_binary(
|
||||
do_log(log, "[resolve] user path not found, falling through");
|
||||
}
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(bin_dir) = exe.parent() {
|
||||
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||
let p = bin_dir.join(name);
|
||||
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(bin_dir.to_path_buf()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let resource_dir = {
|
||||
let raw = app.path().resource_dir().unwrap_or_default();
|
||||
@@ -458,11 +474,13 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
|
||||
e
|
||||
})?;
|
||||
|
||||
let rootdir_flag = format!(
|
||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||
data_dir.to_string_lossy()
|
||||
);
|
||||
invocation.args.insert(0, rootdir_flag);
|
||||
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||
let rootdir_flag = format!(
|
||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||
data_dir.to_string_lossy()
|
||||
);
|
||||
invocation.args.insert(0, rootdir_flag);
|
||||
}
|
||||
|
||||
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||
|
||||
@@ -569,11 +587,49 @@ fn restart_app(app: tauri::AppHandle) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_path(path: String) -> Result<(), String> {
|
||||
let p = std::path::Path::new(path.trim());
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.set_title("Choose Downloads Folder")
|
||||
.blocking_pick_folder()
|
||||
.map(|p| p.to_string())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_discord_rpc::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
@@ -592,6 +648,8 @@ pub fn run() {
|
||||
list_releases,
|
||||
download_and_install_update,
|
||||
restart_app,
|
||||
open_path,
|
||||
pick_downloads_folder,
|
||||
])
|
||||
.setup(|_app| Ok(()))
|
||||
.on_window_event(|window, event| {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.7.1",
|
||||
"identifier": "dev.moku.app",
|
||||
"version": "0.8.0",
|
||||
"identifier": "io.github.Youwes09.Moku.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { gql } from "./lib/client";
|
||||
import logoUrl from "./assets/moku-icon-splash.svg";
|
||||
import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
|
||||
@@ -69,7 +70,8 @@
|
||||
}
|
||||
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const win = getCurrentWindow();
|
||||
const win = getCurrentWindow();
|
||||
const isWindows = platform() === "windows";
|
||||
|
||||
let serverProbeOk = $state(false);
|
||||
let appReady = $state(false);
|
||||
@@ -155,11 +157,31 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||
|
||||
let paused = false;
|
||||
|
||||
const poll = () => {
|
||||
if (paused) return;
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||
};
|
||||
|
||||
poll();
|
||||
pollInterval = setInterval(poll, 2000);
|
||||
return () => clearInterval(pollInterval);
|
||||
|
||||
const onVisibility = () => { paused = document.hidden; };
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
let unlistenFocus: (() => void) | undefined;
|
||||
win.onFocusChanged(({ payload: focused }) => {
|
||||
paused = !focused;
|
||||
}).then(fn => { unlistenFocus = fn; });
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
unlistenFocus?.();
|
||||
};
|
||||
});
|
||||
|
||||
async function checkForUpdateSilently() {
|
||||
@@ -452,7 +474,6 @@
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
|
||||
/* Auth overlay — floats above the SplashScreen */
|
||||
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; }
|
||||
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
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";
|
||||
@@ -26,10 +24,6 @@
|
||||
<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"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
|
||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
|
||||
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
||||
import type { NavPage } from "../../store/state.svelte";
|
||||
|
||||
@@ -8,17 +8,16 @@
|
||||
{ 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;
|
||||
store.navPage = id;
|
||||
store.activeManga = null;
|
||||
store.activeSource = null;
|
||||
store.genreFilter = "";
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
|
||||
@@ -324,8 +324,10 @@
|
||||
ro.observe(el);
|
||||
syncSize();
|
||||
|
||||
let raf = 0, t0 = -1;
|
||||
let raf = 0, t0 = -1, paused = false;
|
||||
|
||||
function frame(now: number) {
|
||||
if (paused) { raf = 0; return; }
|
||||
raf = requestAnimationFrame(frame);
|
||||
if (!live) return;
|
||||
if (t0 < 0) t0 = now;
|
||||
@@ -333,8 +335,34 @@
|
||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
||||
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
paused = true;
|
||||
t0 = -1;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (!paused) return;
|
||||
paused = false;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
document.hidden ? pause() : resume();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
const unlistenFocus = win.onFocusChanged(({ payload: focused }) => {
|
||||
focused ? resume() : pause();
|
||||
});
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
unlistenFocus.then(f => f());
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -33,8 +33,11 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const activeIds = new Set(store.toasts.map(t => t.id));
|
||||
store.toasts.forEach(schedule);
|
||||
return () => timers.forEach(clearTimeout);
|
||||
for (const [id, timer] of timers) {
|
||||
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); }
|
||||
}
|
||||
});
|
||||
|
||||
const icons: Record<Toast["kind"], string> = {
|
||||
@@ -63,7 +66,7 @@
|
||||
</span>
|
||||
<div class="body">
|
||||
<p class="title">{t.title}</p>
|
||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||
<p class="sub">{t.body ?? '\u00a0'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -78,22 +81,21 @@
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
pointer-events: none;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 10px var(--sp-3) 10px 0;
|
||||
gap: 10px;
|
||||
padding: 12px var(--sp-3) 12px 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
|
||||
pointer-events: all;
|
||||
min-width: 200px;
|
||||
width: 280px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
@@ -126,7 +128,7 @@
|
||||
@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; }
|
||||
100% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: 0; margin-bottom: -5px; }
|
||||
}
|
||||
|
||||
.accent-bar {
|
||||
@@ -134,7 +136,7 @@
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0 2px 2px 0;
|
||||
margin-right: 2px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.toast-success .accent-bar { background: var(--accent-fg); }
|
||||
@@ -159,7 +161,7 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -168,7 +170,10 @@
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: 1.3;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sub {
|
||||
@@ -176,6 +181,7 @@
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -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>
|
||||
@@ -17,6 +17,7 @@
|
||||
let refreshing = $state(false);
|
||||
let filter: Filter = $state("installed");
|
||||
let search = $state("");
|
||||
let langFilter = $state<string | null>(null);
|
||||
let working = $state(new Set<string>());
|
||||
let expanded = $state(new Set<string>());
|
||||
let panel: Panel = $state(null);
|
||||
@@ -100,9 +101,17 @@
|
||||
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 matchLang = langFilter === null || e.lang === langFilter;
|
||||
return matchSearch && matchFilter && matchLang;
|
||||
}));
|
||||
|
||||
const availableLangs = $derived(
|
||||
[...new Set(extensions
|
||||
.filter((e) => filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true)
|
||||
.map((e) => e.lang)
|
||||
)].sort()
|
||||
);
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const map = new Map<string, Extension[]>();
|
||||
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
|
||||
@@ -121,6 +130,8 @@
|
||||
{ id: "all", label: "All" },
|
||||
];
|
||||
|
||||
function setFilter(f: Filter) { filter = f; langFilter = null; }
|
||||
|
||||
function toggleExpand(base: string) {
|
||||
const next = new Set(expanded);
|
||||
next.has(base) ? next.delete(base) : next.add(base);
|
||||
@@ -133,7 +144,7 @@
|
||||
<h1 class="heading">Extensions</h1>
|
||||
<div class="tabs">
|
||||
{#each FILTERS as f}
|
||||
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
||||
<button class="tab" class:active={filter === f.id} onclick={() => setFilter(f.id)}>
|
||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -144,10 +155,10 @@
|
||||
<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" />
|
||||
<Plus 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" />
|
||||
<GitBranch size={14} weight="light" />
|
||||
</button>
|
||||
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
@@ -155,6 +166,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if availableLangs.length > 1}
|
||||
<div class="lang-bar">
|
||||
<button class="lang-pill" class:active={langFilter === null} onclick={() => langFilter = null}>All</button>
|
||||
{#each availableLangs as lang}
|
||||
<button class="lang-pill" class:active={langFilter === lang} onclick={() => langFilter = langFilter === lang ? null : lang}>{lang.toUpperCase()}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if panel === "apk"}
|
||||
<div class="ext-panel">
|
||||
<div class="panel-header">
|
||||
@@ -311,6 +331,10 @@
|
||||
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
||||
|
||||
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
|
||||
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
|
||||
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.lang-pill.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
||||
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<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 { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets, Bell } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { getBlobUrl } from "../../lib/imageCache";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
|
||||
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter, clearLibraryUpdates } from "../../store/state.svelte";
|
||||
import type { HistoryEntry } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
import { buildReaderChapterList } from "../../lib/chapterList";
|
||||
@@ -36,26 +36,16 @@
|
||||
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, () =>
|
||||
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);
|
||||
})
|
||||
)
|
||||
.then(m => { libraryManga = m; })
|
||||
.catch(console.error)
|
||||
.finally(() => loadingLibrary = false);
|
||||
}
|
||||
@@ -79,15 +69,6 @@
|
||||
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[] = [];
|
||||
@@ -254,11 +235,20 @@
|
||||
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);
|
||||
const libraryUpdates = $derived(store.libraryUpdates.slice(0, 7));
|
||||
const lastRefresh = $derived(store.lastLibraryRefresh);
|
||||
|
||||
function timeAgoRefresh(ts: number): string {
|
||||
if (!ts) return "";
|
||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function handleRowWheel(e: WheelEvent) {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
@@ -450,28 +440,31 @@
|
||||
<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>
|
||||
<span class="section-title"><Bell size={10} weight="bold" /> Updates
|
||||
{#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
|
||||
</span>
|
||||
{#if libraryUpdates.length > 0}
|
||||
<button class="see-all" onclick={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}>Clear <ArrowRight size={9} weight="bold" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if completedManga.length > 0}
|
||||
{#if libraryUpdates.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}>
|
||||
{#each libraryUpdates as u (u.mangaId)}
|
||||
{@const m = libraryManga.find(x => x.id === u.mangaId)}
|
||||
<button class="mini-card" onclick={() => { if (m) store.previewManga = m; }}>
|
||||
<div class="mini-cover-wrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="mini-cover" />
|
||||
<Thumbnail src={u.thumbnailUrl} alt={u.mangaTitle} 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}
|
||||
<p class="mini-card-title">{u.mangaTitle}</p>
|
||||
<p class="mini-card-source">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="bottom-empty">Finish a manga to see it here</p>
|
||||
<p class="bottom-empty">{lastRefresh ? "No new chapters found" : "Check for updates in the library"}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -486,7 +479,7 @@
|
||||
<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-green"><Bell size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{libraryUpdates.length}</span><span class="stat-label">New updates</span></div></div>
|
||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.longestStreakDays}d</span><span class="stat-label">Best streak</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,6 +612,7 @@
|
||||
.bottom-col:last-child { padding-left: var(--sp-4); }
|
||||
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
||||
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
||||
.refresh-age { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
|
||||
.mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
|
||||
.mini-row::-webkit-scrollbar { display: none; }
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown, Check } from "phosphor-svelte";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown, Check, ArrowsClockwise } from "phosphor-svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
|
||||
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER, UPDATE_LIBRARY, LIBRARY_UPDATE_STATUS } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
|
||||
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte";
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "../../store/state.svelte";
|
||||
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories, setLibraryUpdates, addToast } from "../../store/state.svelte";
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "../../store/state.svelte";
|
||||
import type { Manga, Category, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
@@ -275,6 +276,7 @@
|
||||
}));
|
||||
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
|
||||
error = null;
|
||||
await migrateCategorizedToLibrary();
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
@@ -282,6 +284,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateCategorizedToLibrary() {
|
||||
const allCatManga = store.categories.flatMap(c => c.mangas?.nodes ?? []);
|
||||
const orphanIds = [...new Set(allCatManga.filter(m => !m.inLibrary).map(m => m.id))];
|
||||
if (!orphanIds.length) return;
|
||||
await gql(UPDATE_MANGAS, { ids: orphanIds, inLibrary: true }).catch(console.error);
|
||||
allManga = allManga.map(m => orphanIds.includes(m.id) ? { ...m, inLibrary: true } : m);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
retryCount;
|
||||
loading = true; error = null;
|
||||
@@ -344,7 +355,13 @@
|
||||
// 1. Pick the right base list for this tab
|
||||
let items: Manga[];
|
||||
if (store.libraryFilter === "library") {
|
||||
items = allManga;
|
||||
// "Saved" shows all in-library manga so that manga in folders are still visible here.
|
||||
// If the user prefers the old behaviour (only uncategorised), they can toggle it off in settings.
|
||||
if (store.settings.libraryShowAllInSaved ?? true) {
|
||||
items = allManga.filter(m => m.inLibrary);
|
||||
} else {
|
||||
items = categoryMangaMap.get(0) ?? [];
|
||||
}
|
||||
} else if (store.libraryFilter === "downloaded") {
|
||||
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
} else {
|
||||
@@ -424,7 +441,9 @@
|
||||
|
||||
const counts = $derived((() => {
|
||||
const m: Record<string, number> = {
|
||||
library: allManga.length,
|
||||
library: (store.settings.libraryShowAllInSaved ?? true)
|
||||
? allManga.filter(x => x.inLibrary).length
|
||||
: (categoryMangaMap.get(0) ?? []).length,
|
||||
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
||||
};
|
||||
for (const cat of visibleCategories) {
|
||||
@@ -542,6 +561,11 @@
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
});
|
||||
if (!inCat && !manga.inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
}
|
||||
await reloadCategories();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -556,6 +580,11 @@
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
|
||||
const cat = res.createCategory.category;
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
|
||||
if (!manga.inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
}
|
||||
await reloadCategories();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
@@ -568,7 +597,45 @@
|
||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); }
|
||||
|
||||
async function openMangaFolder(m: Manga) {
|
||||
let base = store.settings.serverDownloadsPath?.trim();
|
||||
if (!base) {
|
||||
try { base = await invoke<string>("get_default_downloads_path"); } catch {}
|
||||
}
|
||||
if (!base) {
|
||||
addToast({ kind: "error", title: "No downloads path set", body: "Configure it in Settings → Storage" });
|
||||
return;
|
||||
}
|
||||
const source = m.source?.displayName ?? m.source?.name ?? "";
|
||||
const path = source
|
||||
? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}`
|
||||
: `${base}/mangas/${sanitize(m.title)}`;
|
||||
try {
|
||||
await invoke("open_path", { path });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path });
|
||||
}
|
||||
}
|
||||
|
||||
async function openDownloadsFolder() {
|
||||
let path = store.settings.serverDownloadsPath?.trim();
|
||||
if (!path) {
|
||||
try { path = await invoke<string>("get_default_downloads_path"); } catch {}
|
||||
}
|
||||
if (!path) {
|
||||
addToast({ kind: "error", title: "No downloads path set", body: "Configure it in Settings → Storage" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke("open_path", { path });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path });
|
||||
}
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
const catEntries: MenuEntry[] = visibleCategories.map(cat => {
|
||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
|
||||
return {
|
||||
@@ -579,6 +646,7 @@
|
||||
});
|
||||
return [
|
||||
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||
{ label: "Open in file manager", icon: ArrowSquareOut, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
|
||||
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
||||
{ separator: true },
|
||||
{ label: "Select this manga", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
|
||||
@@ -610,6 +678,92 @@
|
||||
await reloadCategories();
|
||||
}
|
||||
|
||||
let refreshing: boolean = $state(false);
|
||||
let refreshProgress = $state({ finished: 0, total: 0 });
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let refreshDone: boolean = $state(false); // brief "done" flash on button
|
||||
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(newChapters: number, totalUpdated: number) {
|
||||
if (newChapters > 0) {
|
||||
addToast({ kind: "success", title: "Library updated", body: `${newChapters} new chapter${newChapters !== 1 ? "s" : ""} across ${totalUpdated} series` });
|
||||
} else {
|
||||
addToast({ kind: "info", title: "Already up to date", body: "No new chapters found" });
|
||||
}
|
||||
}
|
||||
|
||||
async function startLibraryRefresh() {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
refreshProgress = { finished: 0, total: 0 };
|
||||
|
||||
const prevCounts = new Map(allManga.map(m => [m.id, m.unreadCount ?? 0]));
|
||||
|
||||
let seenWork = false;
|
||||
try {
|
||||
const updateRes = await gql<{ updateLibrary: { updateStatus: { jobsInfo: { isRunning: boolean; totalJobs: number } } } }>(UPDATE_LIBRARY, {});
|
||||
seenWork = updateRes.updateLibrary.updateStatus.jobsInfo.totalJobs > 0;
|
||||
} catch {
|
||||
refreshing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(function poll() {
|
||||
gql<{ libraryUpdateStatus: {
|
||||
jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number };
|
||||
mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[];
|
||||
} }>(LIBRARY_UPDATE_STATUS, {})
|
||||
.then(d => {
|
||||
const { jobsInfo, mangaUpdates } = d.libraryUpdateStatus;
|
||||
refreshProgress = { finished: jobsInfo.finishedJobs, total: jobsInfo.totalJobs };
|
||||
|
||||
if (jobsInfo.totalJobs > 0) seenWork = true;
|
||||
|
||||
if (!jobsInfo.isRunning && seenWork) {
|
||||
refreshing = false;
|
||||
pollTimer = null;
|
||||
|
||||
const entries: LibraryUpdateEntry[] = mangaUpdates
|
||||
.filter(u => u.status === "FINISHED")
|
||||
.reduce<LibraryUpdateEntry[]>((acc, u) => {
|
||||
const prev = prevCounts.get(u.manga.id) ?? 0;
|
||||
const newChapters = Math.max(0, (u.manga.unreadCount ?? 0) - prev);
|
||||
if (newChapters > 0) {
|
||||
acc.push({
|
||||
mangaId: u.manga.id,
|
||||
mangaTitle: u.manga.title,
|
||||
thumbnailUrl: u.manga.thumbnailUrl,
|
||||
newChapters,
|
||||
checkedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
setLibraryUpdates(entries);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
loadData();
|
||||
|
||||
// Done flash on button
|
||||
refreshDone = true;
|
||||
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
|
||||
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
|
||||
|
||||
// Toast summary
|
||||
const totalNew = entries.reduce((s, e) => s + e.newChapters, 0);
|
||||
showToast(totalNew, entries.length);
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
})
|
||||
.catch(() => {
|
||||
refreshing = false;
|
||||
pollTimer = null;
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
||||
ro.observe(scrollEl);
|
||||
@@ -644,6 +798,7 @@
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
unsub();
|
||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("mousedown", onDocMouseDown, true);
|
||||
};
|
||||
@@ -739,7 +894,26 @@
|
||||
<input class="search" placeholder="Search" bind:value={search} />
|
||||
</div>
|
||||
|
||||
<!-- Sort panel -->
|
||||
<button
|
||||
class="icon-btn refresh-btn"
|
||||
class:icon-btn-active={refreshing}
|
||||
class:refresh-btn-done={refreshDone}
|
||||
title={refreshing ? `Checking… ${refreshProgress.finished}/${refreshProgress.total}` : refreshDone ? "Library updated" : "Check for updates"}
|
||||
disabled={refreshing}
|
||||
onclick={startLibraryRefresh}
|
||||
>
|
||||
<ArrowsClockwise size={15} weight="bold" class={refreshing ? "anim-spin" : ""} />
|
||||
{#if refreshing && refreshProgress.total > 0}
|
||||
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Open downloads folder"
|
||||
onclick={openDownloadsFolder}
|
||||
>
|
||||
<FolderSimple size={15} weight="bold" />
|
||||
</button>
|
||||
<div class="sort-panel-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
@@ -843,6 +1017,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Refresh progress bar ──────────────────────────────────────────────── -->
|
||||
{#if refreshing && refreshProgress.total > 0}
|
||||
{@const pct = Math.round((refreshProgress.finished / refreshProgress.total) * 100)}
|
||||
<div class="refresh-bar-wrap" aria-hidden="true">
|
||||
<div class="refresh-bar-fill" style="width:{pct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Selection toolbar ───────────────────────────────────────────────── -->
|
||||
{#if selectMode}
|
||||
<div class="select-bar">
|
||||
@@ -991,6 +1173,9 @@
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; }
|
||||
.refresh-btn:disabled { cursor: default; }
|
||||
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||
|
||||
/* ── Dropdown panels (shared) ───────────────────────────────────────────── */
|
||||
.sort-panel-wrap,
|
||||
@@ -1067,5 +1252,12 @@
|
||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
|
||||
.retry-btn { margin-top: var(--sp-3); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
/* ── Refresh progress bar ───────────────────────────────────────────────── */
|
||||
.refresh-bar-wrap { height: 2px; background: var(--border-dim); flex-shrink: 0; overflow: hidden; }
|
||||
.refresh-bar-fill { height: 100%; background: var(--accent); border-radius: 0 2px 2px 0; transition: width 0.6s ease; }
|
||||
|
||||
/* Done flash on button */
|
||||
.refresh-btn-done { color: var(--color-success, #5cae6e) !important; border-color: color-mix(in srgb, var(--color-success, #5cae6e) 40%, transparent) !important; background: color-mix(in srgb, var(--color-success, #5cae6e) 10%, transparent) !important; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
|
||||
@@ -3,11 +3,95 @@
|
||||
import { gql } from "../../lib/client";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||
import { getPageSet } from "../../lib/cache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw, shouldHideSource, normalizeTitle } from "../../lib/util";
|
||||
import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte";
|
||||
import { store, setSearchPrefill, setPreviewManga, clearSearchCache } from "../../store/state.svelte";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
|
||||
const SEARCH_PAGES = 3;
|
||||
const SEARCH_LIMIT = 200;
|
||||
const SEARCH_CONCUR = 6;
|
||||
|
||||
function dKey(srcId: string, page: number) {
|
||||
return `${srcId}|POPULAR|All:p${page}`;
|
||||
}
|
||||
|
||||
let srch_results: Manga[] = $state([]);
|
||||
let srch_loading = $state(false);
|
||||
let srch_abortCtrl: AbortController | null = null;
|
||||
|
||||
function srch_filterOut(mangas: Manga[]): Manga[] {
|
||||
return dedupeMangaByTitle(
|
||||
dedupeMangaById(mangas.filter(m => !shouldHideNsfw(m, store.settings))),
|
||||
store.settings.mangaLinks,
|
||||
);
|
||||
}
|
||||
|
||||
function srch_rotatedSources(sources: Source[]): Source[] {
|
||||
const lang = store.settings?.preferredExtensionLang || "en";
|
||||
const eligible = sources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of eligible) {
|
||||
const existing = map.get(s.name);
|
||||
if (!existing) { map.set(s.name, s); continue; }
|
||||
if (s.lang === lang && existing.lang !== lang) map.set(s.name, s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function srch_push(incoming: Manga[]) {
|
||||
const filtered = srch_filterOut(incoming);
|
||||
if (!filtered.length) return;
|
||||
srch_results = dedupeMangaByTitle(
|
||||
dedupeMangaById([...srch_results, ...filtered]),
|
||||
store.settings.mangaLinks,
|
||||
).slice(0, SEARCH_LIMIT);
|
||||
}
|
||||
|
||||
async function srch_fanOut(sources: Source[], signal: AbortSignal) {
|
||||
const srcs = srch_rotatedSources(sources);
|
||||
if (!srcs.length) return;
|
||||
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < srcs.length) {
|
||||
if (signal.aborted) return;
|
||||
const src = srcs[i++];
|
||||
for (let page = 1; page <= SEARCH_PAGES; page++) {
|
||||
if (signal.aborted) return;
|
||||
const key = dKey(src.id, page);
|
||||
let mangas: Manga[];
|
||||
if (store.searchCache?.has(key)) {
|
||||
mangas = store.searchCache.get(key)!;
|
||||
} else {
|
||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "POPULAR", page, query: null },
|
||||
signal,
|
||||
).then(d => d.fetchSourceManga).catch(() => null);
|
||||
if (!result || signal.aborted) break;
|
||||
mangas = result.mangas;
|
||||
store.searchCache?.set(key, mangas);
|
||||
if (!result.hasNextPage) { srch_push(mangas); break; }
|
||||
}
|
||||
srch_push(mangas);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(SEARCH_CONCUR, srcs.length) }, worker));
|
||||
}
|
||||
|
||||
function srch_start(sources: Source[]) {
|
||||
if (srch_results.length > 0) return;
|
||||
srch_abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
srch_abortCtrl = ctrl;
|
||||
srch_loading = true;
|
||||
srch_fanOut(sources, ctrl.signal)
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!ctrl.signal.aborted) srch_loading = false; });
|
||||
}
|
||||
|
||||
type SearchTab = "keyword" | "tag" | "source";
|
||||
type TagMode = "AND" | "OR";
|
||||
|
||||
@@ -18,7 +102,6 @@
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ── Cached manga entry for tag/source browsing ────────────────────────────
|
||||
interface CachedManga {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -27,11 +110,11 @@
|
||||
status: string;
|
||||
genre: string[];
|
||||
sourceId: string;
|
||||
genreEnriched: boolean; // true once fetchManga has been called for this entry
|
||||
genreEnriched: boolean;
|
||||
}
|
||||
|
||||
const CONCURRENCY = 6;
|
||||
const POPULAR_PAGES = 3; // pages to pre-fetch per source
|
||||
const CONCURRENCY = 6;
|
||||
const POPULAR_PAGES = 3;
|
||||
|
||||
const COMMON_GENRES = [
|
||||
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
|
||||
@@ -49,7 +132,6 @@
|
||||
{ value: "UNKNOWN", label: "Unknown" },
|
||||
];
|
||||
|
||||
// ── Concurrency helper ────────────────────────────────────────────────────
|
||||
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
@@ -62,21 +144,16 @@
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||
}
|
||||
|
||||
// ── Source dedup by preferred lang ────────────────────────────────────────
|
||||
// For each unique source name, keep only the preferred-lang variant (or the
|
||||
// first alphabetically if preferred lang isn't available). This collapses
|
||||
// MangaDex (60+ lang variants) and Manga Ball (40+) down to one each.
|
||||
function dedupSourcesByLang(sources: Source[], preferredLang: string): Source[] {
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of sources) {
|
||||
if (s.id === "0") continue; // skip local source
|
||||
if (s.id === "0") continue;
|
||||
const key = s.name;
|
||||
const existing = map.get(key);
|
||||
if (!existing) {
|
||||
map.set(key, s);
|
||||
continue;
|
||||
}
|
||||
// Prefer the preferred lang; otherwise keep alphabetically first lang
|
||||
const existingIsPreferred = existing.lang === preferredLang;
|
||||
const newIsPreferred = s.lang === preferredLang;
|
||||
if (newIsPreferred && !existingIsPreferred) {
|
||||
@@ -88,15 +165,12 @@
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
// ── In-memory source manga cache ─────────────────────────────────────────
|
||||
// Keyed by manga id. Shared across tag searches for the session.
|
||||
const sourceCache = new Map<number, CachedManga>();
|
||||
let sourceCacheReady = $state(false); // true once phase 1 (popular fetch) is done
|
||||
let sourceCacheLoading = $state(false);
|
||||
let sourceCacheEnriching = $state(false); // true while background genre enrichment runs
|
||||
let sourceCacheReady = $state(false);
|
||||
let sourceCacheLoading = $state(false);
|
||||
let sourceCacheEnriching = $state(false);
|
||||
let sourceCacheAbort: AbortController | null = null;
|
||||
|
||||
// Phase 1: fetch 3 pages of POPULAR per deduped source, store in sourceCache
|
||||
async function buildSourceCache(sources: Source[], signal: AbortSignal) {
|
||||
const pages = [1, 2, 3];
|
||||
const tasks: { src: Source; page: number }[] = [];
|
||||
@@ -129,12 +203,10 @@
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
// Individual source failures are silently skipped
|
||||
}
|
||||
}, signal);
|
||||
}
|
||||
|
||||
// Phase 2: background genre enrichment — only for entries with empty genre[]
|
||||
async function enrichGenres(signal: AbortSignal) {
|
||||
const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched);
|
||||
if (!unenriched.length) return;
|
||||
@@ -150,13 +222,12 @@
|
||||
if (signal.aborted) return;
|
||||
const updated = sourceCache.get(entry.id);
|
||||
if (updated) {
|
||||
updated.genre = d.fetchManga.manga.genre ?? [];
|
||||
updated.status = d.fetchManga.manga.status ?? updated.status;
|
||||
updated.genre = d.fetchManga.manga.genre ?? [];
|
||||
updated.status = d.fetchManga.manga.status ?? updated.status;
|
||||
updated.genreEnriched = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
// Mark as enriched anyway so we don't retry endlessly
|
||||
const updated = sourceCache.get(entry.id);
|
||||
if (updated) updated.genreEnriched = true;
|
||||
}
|
||||
@@ -164,7 +235,6 @@
|
||||
if (!signal.aborted) sourceCacheEnriching = false;
|
||||
}
|
||||
|
||||
// MANGAS_BY_GENRE — local library query
|
||||
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) {
|
||||
@@ -178,7 +248,6 @@
|
||||
}
|
||||
`;
|
||||
|
||||
// Build GraphQL filter for local library query
|
||||
function buildTagFilter(
|
||||
tags: string[],
|
||||
mode: TagMode,
|
||||
@@ -202,15 +271,12 @@
|
||||
return { and: [genrePart, statusPart] };
|
||||
}
|
||||
|
||||
// Filter the in-memory source cache by active tags + statuses
|
||||
function filterSourceCache(
|
||||
tags: string[],
|
||||
mode: TagMode,
|
||||
statuses: string[],
|
||||
): CachedManga[] {
|
||||
return [...sourceCache.values()].filter((m) => {
|
||||
if (!shouldHideNsfw(m as any, store.settings)) return false; // keep non-nsfw
|
||||
// Actually: shouldHideNsfw returns true when we SHOULD hide, so:
|
||||
if (shouldHideNsfw(m as any, store.settings)) return false;
|
||||
|
||||
const statusMatch =
|
||||
@@ -230,7 +296,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Global state ──────────────────────────────────────────────────────────
|
||||
let tab: SearchTab = $state("keyword");
|
||||
let preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||
|
||||
@@ -249,13 +314,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Load sources then kick off the cache build
|
||||
loadingSources = true;
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => {
|
||||
allSources = d.sources.nodes.filter((src: Source) => src.id !== "0");
|
||||
// Kick off source cache build immediately after sources load
|
||||
startSourceCacheBuild();
|
||||
srch_start(allSources);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingSources = false; });
|
||||
@@ -276,7 +340,6 @@
|
||||
if (ctrl.signal.aborted) return;
|
||||
sourceCacheReady = true;
|
||||
sourceCacheLoading = false;
|
||||
// Phase 2: enrich genres in background at low priority
|
||||
enrichGenres(ctrl.signal);
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -288,14 +351,13 @@
|
||||
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||
|
||||
// ── Keyword tab ───────────────────────────────────────────────────────────
|
||||
let kw_query = $state("");
|
||||
let kw_submitted = $state("");
|
||||
let kw_results: SourceResult[] = $state([]);
|
||||
let kw_showAdvanced = $state(false);
|
||||
let kw_selectedLangs: Set<string> = $state(new Set());
|
||||
let kw_inputEl: HTMLInputElement | null = $state(null);
|
||||
let kw_abortCtrl: AbortController | null = null;
|
||||
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
$effect(() => {
|
||||
if (allSources.length) {
|
||||
@@ -307,7 +369,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!loadingSources && pendingPrefill && !kw_submitted && allSources.length) {
|
||||
if (!loadingSources && pendingPrefill && allSources.length) {
|
||||
const q = pendingPrefill;
|
||||
pendingPrefill = "";
|
||||
kw_query = q;
|
||||
@@ -315,6 +377,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const q = kw_query;
|
||||
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||
if (!q.trim()) {
|
||||
kw_abortCtrl?.abort();
|
||||
kw_results = [];
|
||||
return;
|
||||
}
|
||||
kw_debounceTimer = setTimeout(() => {
|
||||
kwDoSearch(q);
|
||||
}, 350);
|
||||
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
|
||||
});
|
||||
|
||||
function kwGetVisibleSources(): Source[] {
|
||||
let filtered = allSources;
|
||||
if (kw_selectedLangs.size > 0)
|
||||
@@ -332,8 +408,7 @@
|
||||
kw_abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
kw_abortCtrl = ctrl;
|
||||
kw_submitted = trimmed;
|
||||
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
|
||||
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
|
||||
await runConcurrent(visible, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
@@ -364,14 +439,23 @@
|
||||
const kw_visibleCount = $derived(kwGetVisibleSources().length);
|
||||
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
|
||||
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
|
||||
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
|
||||
|
||||
const kw_flatResults = $derived.by(() => {
|
||||
const all = kw_results.flatMap((r) =>
|
||||
r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName }))
|
||||
);
|
||||
return dedupeMangaByTitle(
|
||||
dedupeMangaById(all),
|
||||
store.settings.mangaLinks,
|
||||
) as (Manga & { _sourceName?: string })[];
|
||||
});
|
||||
|
||||
// ── Tag tab ───────────────────────────────────────────────────────────────
|
||||
let tag_activeTags: string[] = $state([]);
|
||||
let tag_activeStatuses: string[] = $state([]);
|
||||
let tag_tagMode: TagMode = $state("AND");
|
||||
let tag_tagFilter = $state("");
|
||||
|
||||
// Local library results
|
||||
let tag_localResults: Manga[] = $state([]);
|
||||
let tag_totalCount = $state(0);
|
||||
let tag_loadingLocal = $state(false);
|
||||
@@ -380,10 +464,13 @@
|
||||
let tag_localHasNext = $state(false);
|
||||
let tag_abortLocal: AbortController | null = null;
|
||||
|
||||
// Source cache results (filtered client-side from sourceCache)
|
||||
let tag_searchSources = $state(false);
|
||||
let tag_sourceFiltered: CachedManga[] = $state([]);
|
||||
|
||||
let tag_sourceFanOut: Manga[] = $state([]);
|
||||
let tag_fanOutLoading = $state(false);
|
||||
let tag_fanOutAbort: AbortController | null = null;
|
||||
|
||||
const tag_filteredGenres = $derived.by(() => {
|
||||
const q = tag_tagFilter.trim().toLowerCase();
|
||||
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
||||
@@ -391,7 +478,6 @@
|
||||
|
||||
const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
|
||||
|
||||
// Local library fetch — triggered when tags or statuses change
|
||||
$effect(() => {
|
||||
const _tags = tag_activeTags;
|
||||
const _mode = tag_tagMode;
|
||||
@@ -399,7 +485,6 @@
|
||||
untrack(() => tagFetchLocal(_tags, _mode, _statuses));
|
||||
});
|
||||
|
||||
// Source cache filter — reactive to filters + cache readiness
|
||||
$effect(() => {
|
||||
const _tags = tag_activeTags;
|
||||
const _mode = tag_tagMode;
|
||||
@@ -415,7 +500,73 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-enable source search when local results are sparse
|
||||
$effect(() => {
|
||||
const _tags = tag_activeTags;
|
||||
const _search = tag_searchSources;
|
||||
untrack(() => {
|
||||
if (_search && _tags.length === 1 && tag_activeStatuses.length === 0) {
|
||||
tagStartFanOut(_tags[0]);
|
||||
} else {
|
||||
tag_fanOutAbort?.abort();
|
||||
tag_fanOutAbort = null;
|
||||
tag_sourceFanOut = [];
|
||||
tag_fanOutLoading = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function tagStartFanOut(genre: string) {
|
||||
tag_fanOutAbort?.abort();
|
||||
const ctrl = new AbortController();
|
||||
tag_fanOutAbort = ctrl;
|
||||
tag_sourceFanOut = [];
|
||||
tag_fanOutLoading = true;
|
||||
|
||||
const srcs = srch_rotatedSources(allSources);
|
||||
const PAGES = 2;
|
||||
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
for (let page = 1; page <= PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const cacheKey = `${src.id}|SEARCH|${genre}:p${page}`;
|
||||
let mangas: Manga[];
|
||||
let hasNextPage = false;
|
||||
|
||||
if (store.searchCache?.has(cacheKey)) {
|
||||
mangas = store.searchCache.get(cacheKey)!;
|
||||
} else {
|
||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||
ctrl.signal,
|
||||
).then(d => d.fetchSourceManga).catch(() => null);
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
mangas = result.mangas;
|
||||
hasNextPage = result.hasNextPage;
|
||||
store.searchCache?.set(cacheKey, mangas);
|
||||
}
|
||||
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
const matching = mangas.filter(m =>
|
||||
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genre.toLowerCase())
|
||||
);
|
||||
const toAdd = (matching.length ? matching : mangas).filter(m => !shouldHideNsfw(m, store.settings));
|
||||
|
||||
if (toAdd.length) {
|
||||
tag_sourceFanOut = dedupeMangaByTitle(
|
||||
dedupeMangaById([...tag_sourceFanOut, ...toAdd]),
|
||||
store.settings.mangaLinks,
|
||||
).slice(0, SEARCH_LIMIT);
|
||||
}
|
||||
|
||||
if (!hasNextPage) return;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) tag_fanOutLoading = false;
|
||||
}
|
||||
|
||||
let tag_autoSearchFired = $state(false);
|
||||
$effect(() => {
|
||||
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
|
||||
@@ -498,13 +649,13 @@
|
||||
tag_searchSources = !tag_searchSources;
|
||||
}
|
||||
|
||||
// Deduplicate merged results: local library wins over source cache on id,
|
||||
// then dedupe by title to avoid cross-source duplicates.
|
||||
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
|
||||
|
||||
const tag_mergedResults = $derived.by(() => {
|
||||
const localMapped = tag_localResults;
|
||||
const sourceMapped: Manga[] = tag_sourceFiltered
|
||||
.filter((m) => !tag_localIds.has(m.id))
|
||||
const fanOutMapped = tag_sourceFanOut.filter(m => !tag_localIds.has(m.id));
|
||||
const cacheMapped: Manga[] = tag_sourceFiltered
|
||||
.filter((m) => !tag_localIds.has(m.id) && !fanOutMapped.some(f => f.id === m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
@@ -514,14 +665,13 @@
|
||||
status: m.status,
|
||||
} as Manga));
|
||||
return dedupeMangaByTitle(
|
||||
dedupeMangaById([...localMapped, ...sourceMapped]),
|
||||
dedupeMangaById([...localMapped, ...fanOutMapped, ...cacheMapped]),
|
||||
store.settings.mangaLinks,
|
||||
);
|
||||
});
|
||||
|
||||
const tag_totalVisible = $derived(tag_mergedResults.length);
|
||||
|
||||
// ── Source browse tab ─────────────────────────────────────────────────────
|
||||
let src_selectedLang = $state(preferredLang || "all");
|
||||
let src_activeSource: Source | null = $state(null);
|
||||
let src_browseResults: Manga[] = $state([]);
|
||||
@@ -540,13 +690,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Source tab visible sources — deduped by preferred lang when showing "all"
|
||||
const src_visibleSources = $derived.by(() => {
|
||||
const hide = (s: Source) => shouldHideSource(s, store.settings);
|
||||
if (src_selectedLang !== "all") {
|
||||
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
|
||||
}
|
||||
// Dedup by name, prefer preferredLang
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of allSources) {
|
||||
if (hide(s)) continue;
|
||||
@@ -603,9 +751,12 @@
|
||||
|
||||
onDestroy(() => {
|
||||
kw_abortCtrl?.abort();
|
||||
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||
tag_abortLocal?.abort();
|
||||
tag_fanOutAbort?.abort();
|
||||
src_abortCtrl?.abort();
|
||||
sourceCacheAbort?.abort();
|
||||
srch_abortCtrl?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -646,10 +797,14 @@
|
||||
bind:value={kw_query}
|
||||
class="searchInput"
|
||||
placeholder="Search across sources…"
|
||||
onkeydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)}
|
||||
use:focusOnMount
|
||||
/>
|
||||
{#if kw_query}
|
||||
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; kw_inputEl?.focus(); }}>×</button>
|
||||
{#if kw_anyLoading}
|
||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
{:else if kw_query}
|
||||
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; kw_results = []; kw_inputEl?.focus(); }}>×</button>
|
||||
{/if}
|
||||
{#if hasMultipleLangs}
|
||||
<button
|
||||
@@ -663,15 +818,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="searchBtn" onclick={() => kwDoSearch(kw_query)} disabled={!kw_query.trim() || loadingSources}>
|
||||
{#if loadingSources}
|
||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
Search
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if hasMultipleLangs && kw_showAdvanced}
|
||||
@@ -698,83 +844,89 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !kw_submitted}
|
||||
<div class="empty">
|
||||
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||
</svg>
|
||||
<p class="emptyText">Search across sources</p>
|
||||
<p class="emptyHint">
|
||||
{#if hasMultipleLangs}
|
||||
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""}
|
||||
{:else}
|
||||
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""}
|
||||
{#if !kw_query.trim()}
|
||||
{#if srch_loading && srch_results.length === 0}
|
||||
<div class="searchGrid">
|
||||
{#each Array(24) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if srch_results.length > 0}
|
||||
<div class="searchHeader">
|
||||
<span class="searchLabel">Popular right now</span>
|
||||
</div>
|
||||
<div class="searchGrid">
|
||||
{#each srch_results as m (m.id)}
|
||||
<button class="srchCard" onclick={() => setPreviewManga(m)}>
|
||||
<div class="srchCoverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||
<div class="srchGradient"></div>
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
<div class="srchFooter">
|
||||
<p class="srchTitle">{m.title}</p>
|
||||
{#if m.source?.displayName}<p class="srchSource">{m.source.displayName}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if srch_loading}
|
||||
{#each Array(6) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div></div>
|
||||
{/each}
|
||||
{/if}
|
||||
</p>
|
||||
{#if hasMultipleLangs && !kw_showAdvanced}
|
||||
<button class="advancedLinkStandalone" onclick={() => (kw_showAdvanced = true)}>
|
||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
|
||||
</svg>
|
||||
Adjust language filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="results">
|
||||
{#if kw_results.length === 0}
|
||||
<div class="empty">
|
||||
<svg width="20" height="20" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each kw_results.filter((r) => r.mangas.length > 0 || r.loading || r.error) as { source, mangas, loading, error } (source.id)}
|
||||
<div class="sourceSection">
|
||||
<div class="sourceHeader">
|
||||
<Thumbnail src={source.iconUrl} alt={source.displayName} class="sourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="sourceName">{source.displayName}</span>
|
||||
{#if hasMultipleLangs}<span class="sourceLang">{source.lang.toUpperCase()}</span>{/if}
|
||||
{#if loading}
|
||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);margin-left:auto" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
{:else if mangas.length > 0}
|
||||
<span class="resultCount">{mangas.length} results</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="sourceError">{error}</p>
|
||||
{:else if loading}
|
||||
<div class="sourceRow">
|
||||
{#each Array(4) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if mangas.length > 0}
|
||||
<div class="sourceRow">
|
||||
{#each mangas.slice(0, (store.settings.renderLimit ?? 48)) as m (m.id)}
|
||||
<button class="card" onclick={() => setPreviewManga(m)}>
|
||||
<div class="coverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="cardTitle">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||
</svg>
|
||||
<p class="emptyText">Search across sources</p>
|
||||
<p class="emptyHint">
|
||||
{#if hasMultipleLangs}
|
||||
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""}
|
||||
{:else}
|
||||
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if kw_allDone && !kw_hasResults}
|
||||
<div class="empty">
|
||||
<p class="emptyText">No results for "{kw_submitted}"</p>
|
||||
<p class="emptyHint">Try a different spelling or fewer words</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if kw_flatResults.length > 0}
|
||||
<div class="searchHeader">
|
||||
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
<div class="searchGrid">
|
||||
{#each kw_flatResults.slice(0, store.settings.renderLimit ?? 48) as m (m.id)}
|
||||
<button class="srchCard" onclick={() => setPreviewManga(m)}>
|
||||
<div class="srchCoverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||
<div class="srchGradient"></div>
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
<div class="srchFooter">
|
||||
<p class="srchTitle">{m.title}</p>
|
||||
{#if (m as any)._sourceName}<p class="srchSource">{(m as any)._sourceName}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if kw_anyLoading}
|
||||
{#each Array(6) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div></div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if kw_anyLoading}
|
||||
<div class="searchGrid">
|
||||
{#each Array(12) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if kw_allDone && !kw_hasResults}
|
||||
<div class="empty">
|
||||
<p class="emptyText">No results for "{kw_query.trim()}"</p>
|
||||
<p class="emptyHint">Try a different spelling or fewer words</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{:else if tab === "tag"}
|
||||
@@ -849,7 +1001,7 @@
|
||||
disabled={!sourceCacheReady && !sourceCacheLoading}
|
||||
onclick={tagToggleSearchSources}
|
||||
>
|
||||
{#if sourceCacheLoading}
|
||||
{#if sourceCacheLoading || tag_fanOutLoading}
|
||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
@@ -1105,8 +1257,6 @@
|
||||
.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.advancedDivider { height: 1px; background: var(--border-dim); margin: 2px 0; }
|
||||
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.advancedLinkStandalone { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; padding: 0; cursor: pointer; opacity: 0.7; transition: opacity var(--t-base); }
|
||||
.advancedLinkStandalone:hover { opacity: 1; }
|
||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
||||
.emptyIcon { color: var(--text-faint); }
|
||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||
@@ -1191,6 +1341,19 @@
|
||||
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
|
||||
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180, 60, 60, 0.08)); border: 1px solid rgba(180, 60, 60, 0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
|
||||
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
|
||||
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; }
|
||||
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.srchCard:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||
.srchCoverWrap { 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); }
|
||||
.srchGradient { 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; }
|
||||
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
||||
.srchTitle { 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); }
|
||||
.srchSource { 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; }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
||||
import { setReading } from "../../lib/discord";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { FitMode, MarkerColor } from "../../store/state.svelte";
|
||||
|
||||
const AVG_MIN_PER_PAGE = 0.33;
|
||||
const READ_LINE_PCT = 0.20;
|
||||
const READ_LINE_PCT = 0.50;
|
||||
const ZOOM_STEP = 0.05;
|
||||
const ZOOM_MIN = 0.1;
|
||||
const ZOOM_MAX = 1.0;
|
||||
@@ -37,6 +38,8 @@
|
||||
const pageCache = new Map<number, string[]>();
|
||||
const inflight = new Map<number, Promise<string[]>>();
|
||||
|
||||
const win = getCurrentWindow();
|
||||
|
||||
const useBlob = $derived((appStore.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
|
||||
|
||||
function resolveUrl(url: string, priority = 0): Promise<string> {
|
||||
@@ -123,6 +126,8 @@
|
||||
let error: string | null = $state(null);
|
||||
let dlOpen = $state(false);
|
||||
let zoomOpen = $state(false);
|
||||
let winOpen = $state(false);
|
||||
let isFullscreen = $state(false);
|
||||
let uiVisible = $state(true);
|
||||
let pageReady = $state(false);
|
||||
let pageGroups: number[][] = $state([]);
|
||||
@@ -134,7 +139,8 @@
|
||||
let markedRead = new Set<number>();
|
||||
let appending = false;
|
||||
let abortCtrl: AbortController | null = null;
|
||||
let hasNavigated = false;
|
||||
let hasNavigated = false;
|
||||
let startAtLastPage = false;
|
||||
let resumePage = $state(0);
|
||||
let resumeDismissed = $state(false);
|
||||
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -159,6 +165,16 @@
|
||||
let sliderDragging = $state(false);
|
||||
let sliderHover = $state(false);
|
||||
|
||||
let inspectScale = $state(1);
|
||||
let inspectPanX = $state(0);
|
||||
let inspectPanY = $state(0);
|
||||
let inspectDragging = false;
|
||||
let inspectDragMoved = false;
|
||||
let inspectDragStartX = 0;
|
||||
let inspectDragStartY = 0;
|
||||
let inspectPanStartX = 0;
|
||||
let inspectPanStartY = 0;
|
||||
|
||||
const rtl = $derived(store.settings.readingDirection === "rtl");
|
||||
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
||||
const style = $derived((store.settings.pageStyle ?? "single") as PageStyle);
|
||||
@@ -175,8 +191,8 @@
|
||||
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
||||
: store.activeChapter
|
||||
);
|
||||
const currentBookmark = $derived(displayChapter ? store.bookmarks.find(b => b.chapterId === displayChapter!.id) : undefined);
|
||||
const isBookmarked = $derived(!!currentBookmark && currentBookmark.pageNumber === store.pageNumber);
|
||||
const currentBookmark = $derived(store.activeManga ? store.bookmarks.find(b => b.mangaId === store.activeManga!.id) : undefined);
|
||||
const isBookmarked = $derived(!!currentBookmark && currentBookmark.chapterId === displayChapter?.id && currentBookmark.pageNumber === store.pageNumber);
|
||||
|
||||
const currentPageMarkers = $derived(
|
||||
displayChapter ? store.getMarkersForPage(displayChapter.id, store.pageNumber) : []
|
||||
@@ -230,11 +246,12 @@
|
||||
: []
|
||||
);
|
||||
|
||||
const currentGroup = $derived(
|
||||
style === "double" && pageGroups.length
|
||||
const currentGroup = $derived.by(() => {
|
||||
const group = style === "double" && pageGroups.length
|
||||
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
|
||||
: [store.pageNumber]
|
||||
);
|
||||
: [store.pageNumber];
|
||||
return rtl ? [...group].reverse() : group;
|
||||
});
|
||||
|
||||
const sliderPage = $derived.by(() => {
|
||||
if (style === "double" && pageGroups.length) {
|
||||
@@ -249,7 +266,8 @@
|
||||
return lastPage || 1;
|
||||
});
|
||||
|
||||
const sliderPct = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
|
||||
const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
|
||||
const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw);
|
||||
|
||||
$effect(() => {
|
||||
const chapter = displayChapter;
|
||||
@@ -268,6 +286,8 @@
|
||||
abortCtrl = ctrl;
|
||||
hasNavigated = false;
|
||||
appending = false;
|
||||
const goToLast = startAtLastPage;
|
||||
startAtLastPage = false;
|
||||
markedRead = new Set();
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -292,7 +312,8 @@
|
||||
const urls = await fetchPages(id, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
|
||||
if (ctrl.signal.aborted) return;
|
||||
store.pageUrls = urls;
|
||||
if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||
if (goToLast) store.pageNumber = urls.length;
|
||||
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||
pageReady = true;
|
||||
loading = false;
|
||||
if (adjacent.next) fetchPages(adjacent.next.id).catch(() => {});
|
||||
@@ -331,7 +352,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
||||
$effect(() => { if (style !== "longstrip") { void store.pageNumber; inspectScale = 1; inspectPanX = 0; inspectPanY = 0; } });
|
||||
|
||||
$effect(() => {
|
||||
const chId = visibleChapterId;
|
||||
@@ -472,7 +493,7 @@
|
||||
while (i <= snap.length) {
|
||||
const a = aspects[i - 1];
|
||||
if (a > 1.2 || i === snap.length) { groups.push([i++]); }
|
||||
else { groups.push(rtl ? [i + 1, i] : [i, i + 1]); i += 2; }
|
||||
else { groups.push([i, i + 1]); i += 2; }
|
||||
}
|
||||
pageGroups = groups;
|
||||
});
|
||||
@@ -517,6 +538,8 @@
|
||||
if (style === "longstrip" && visibleChapterId && chapterId !== visibleChapterId) return;
|
||||
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, readAt: Date.now() });
|
||||
if (autoBookmark) {
|
||||
const existing = store.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
|
||||
if (existing) removeBookmark(existing.chapterId);
|
||||
addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
||||
}
|
||||
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
|
||||
@@ -599,7 +622,7 @@
|
||||
else closeReader();
|
||||
} else {
|
||||
if (gi > 0) store.pageNumber = pageGroups[gi - 1][0];
|
||||
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
||||
else if (adjacent.prev) { startAtLastPage = true; openReader(adjacent.prev, store.activeChapterList); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +654,7 @@
|
||||
function goBack() {
|
||||
if (loading) return;
|
||||
if (style === "longstrip") {
|
||||
if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
||||
if (adjacent.prev) { startAtLastPage = true; openReader(adjacent.prev, store.activeChapterList); }
|
||||
return;
|
||||
}
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||
@@ -639,7 +662,7 @@
|
||||
if (store.pageNumber > 1) {
|
||||
if (style === "fade") { animateFade(() => { store.pageNumber--; }); }
|
||||
else { store.pageNumber--; }
|
||||
} else if (adjacent.prev) { openReader(adjacent.prev, store.activeChapterList); }
|
||||
} else if (adjacent.prev) { startAtLastPage = true; openReader(adjacent.prev, store.activeChapterList); }
|
||||
}
|
||||
|
||||
const goNext = $derived(rtl ? goBack : goForward);
|
||||
@@ -684,6 +707,8 @@
|
||||
removeBookmark(ch.id);
|
||||
resumeVisible = false;
|
||||
} else {
|
||||
const existing = store.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== ch.id);
|
||||
if (existing) removeBookmark(existing.chapterId);
|
||||
addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber: store.pageNumber });
|
||||
}
|
||||
}
|
||||
@@ -705,6 +730,7 @@
|
||||
markerOpen = !markerOpen;
|
||||
zoomOpen = false;
|
||||
dlOpen = false;
|
||||
winOpen = false;
|
||||
}
|
||||
|
||||
function commitMarker() {
|
||||
@@ -750,61 +776,92 @@
|
||||
function showUi() {
|
||||
uiVisible = true;
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => { if (!markerOpen) uiVisible = false; }, 3000);
|
||||
hideTimer = setTimeout(() => { if (!markerOpen && !winOpen) uiVisible = false; }, 3000);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (markerOpen) {
|
||||
if (markerOpen || winOpen) {
|
||||
uiVisible = true;
|
||||
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
||||
}
|
||||
});
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
|
||||
const INSPECT_ZOOM_STEP = 0.15;
|
||||
const INSPECT_ZOOM_MAX = 8;
|
||||
|
||||
function getInspectImageEl(): HTMLElement | null {
|
||||
if (!containerEl) return null;
|
||||
return (
|
||||
containerEl.querySelector<HTMLElement>(".inspect-wrap .double-wrap") ??
|
||||
containerEl.querySelector<HTMLElement>(".inspect-wrap img")
|
||||
);
|
||||
}
|
||||
|
||||
function onSliderInput(e: Event) {
|
||||
jumpToPage(Number((e.currentTarget as HTMLInputElement).value));
|
||||
function clampInspectPan(scale: number, px: number, py: number): [number, number] {
|
||||
const img = getInspectImageEl();
|
||||
if (!img) return [px, py];
|
||||
const maxX = Math.max(0, (img.offsetWidth * (scale - 1)) / 2);
|
||||
const maxY = Math.max(0, (img.offsetHeight * (scale - 1)) / 2);
|
||||
return [Math.max(-maxX, Math.min(maxX, px)), Math.max(-maxY, Math.min(maxY, py))];
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
|
||||
return;
|
||||
}
|
||||
if (style === "longstrip") return;
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP;
|
||||
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, inspectScale + delta));
|
||||
if (next === inspectScale) return;
|
||||
if (next === 1) { inspectScale = 1; inspectPanX = 0; inspectPanY = 0; return; }
|
||||
const img = getInspectImageEl();
|
||||
const anchor = img ?? containerEl;
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
|
||||
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
|
||||
const ratio = next / inspectScale;
|
||||
const rawPanX = cx + (inspectPanX - cx) * ratio;
|
||||
const rawPanY = cy + (inspectPanY - cy) * ratio;
|
||||
const [clampedX, clampedY] = clampInspectPan(next, rawPanX, rawPanY);
|
||||
inspectScale = next;
|
||||
inspectPanX = clampedX;
|
||||
inspectPanY = clampedY;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT" || (e.target as HTMLElement).tagName === "TEXTAREA") return;
|
||||
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
|
||||
const r = store.settings.readingDirection === "rtl";
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (markerOpen) { markerOpen = false; return; }
|
||||
if (zoomOpen) { zoomOpen = false; return; }
|
||||
if (dlOpen) { dlOpen = false; return; }
|
||||
if (winOpen) { winOpen = false; return; }
|
||||
closeReader(); return;
|
||||
}
|
||||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); adjustZoom(ZOOM_STEP * 2); return; }
|
||||
if (e.ctrlKey && e.key === "-") { e.preventDefault(); adjustZoom(-ZOOM_STEP * 2); return; }
|
||||
if (e.ctrlKey && e.key === "0") { e.preventDefault(); resetZoom(); return; }
|
||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||||
else if (matchesKeybind(e, kb.turnPageRight)) { e.preventDefault(); goNext(); }
|
||||
else if (matchesKeybind(e, kb.turnPageLeft)) { e.preventDefault(); goPrev(); }
|
||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); store.pageNumber = 1; }
|
||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); store.pageNumber = lastPage; }
|
||||
else if (matchesKeybind(e, kb.chapterRight)) {
|
||||
else if (matchesKeybind(e, kb.turnChapterRight)) {
|
||||
e.preventDefault();
|
||||
const list = store.activeChapterList;
|
||||
const idx = list.findIndex(c => c.id === store.activeChapter?.id);
|
||||
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
||||
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
|
||||
const ch = rtl ? adjacent.prev : adjacent.next;
|
||||
if (ch) { maybeMarkCurrentRead(); openReader(ch, store.activeChapterList); }
|
||||
}
|
||||
else if (matchesKeybind(e, kb.chapterLeft)) {
|
||||
else if (matchesKeybind(e, kb.turnChapterLeft)) {
|
||||
e.preventDefault();
|
||||
const list = store.activeChapterList;
|
||||
const idx = list.findIndex(c => c.id === store.activeChapter?.id);
|
||||
const prev = idx > 0 ? list[idx - 1] : null;
|
||||
if (prev) openReader(prev, list);
|
||||
const ch = rtl ? adjacent.next : adjacent.prev;
|
||||
if (ch) openReader(ch, store.activeChapterList);
|
||||
}
|
||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
|
||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
||||
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); toggleBookmark(); }
|
||||
@@ -813,9 +870,34 @@
|
||||
|
||||
function handleTap(e: MouseEvent) {
|
||||
if (style === "longstrip") return;
|
||||
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
||||
const x = e.clientX / window.innerWidth;
|
||||
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
|
||||
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
|
||||
if (x > 0.6) goNext(); else if (x < 0.4) goPrev();
|
||||
}
|
||||
|
||||
function onInspectMouseDown(e: MouseEvent) {
|
||||
if (style === "longstrip" || inspectScale <= 1) return;
|
||||
inspectDragging = true;
|
||||
inspectDragMoved = false;
|
||||
inspectDragStartX = e.clientX;
|
||||
inspectDragStartY = e.clientY;
|
||||
inspectPanStartX = inspectPanX;
|
||||
inspectPanStartY = inspectPanY;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onInspectMouseMove(e: MouseEvent) {
|
||||
if (!inspectDragging) return;
|
||||
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||
const rawY = inspectPanStartY + (e.clientY - inspectDragStartY);
|
||||
const [cx, cy] = clampInspectPan(inspectScale, rawX, rawY);
|
||||
inspectPanX = cx;
|
||||
inspectPanY = cy;
|
||||
}
|
||||
|
||||
function onInspectMouseUp() {
|
||||
inspectDragging = false;
|
||||
}
|
||||
|
||||
async function runDl(fn: () => Promise<unknown>) {
|
||||
@@ -824,21 +906,35 @@
|
||||
dlBusy = false; dlOpen = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
showUi();
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("wheel", onWheel, { passive: false });
|
||||
window.addEventListener("mousemove", onInspectMouseMove);
|
||||
window.addEventListener("mouseup", onInspectMouseUp);
|
||||
containerEl?.focus({ preventScroll: true });
|
||||
|
||||
const ro = new ResizeObserver(entries => { containerWidth = entries[0].contentRect.width; });
|
||||
isFullscreen = await win.isFullscreen();
|
||||
const unlistenFs = await win.onResized(async () => { isFullscreen = await win.isFullscreen(); });
|
||||
|
||||
let roTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const w = entries[0].contentRect.width;
|
||||
if (roTimer) clearTimeout(roTimer);
|
||||
roTimer = setTimeout(() => { containerWidth = w; roTimer = null; }, 50);
|
||||
});
|
||||
ro.observe(containerEl);
|
||||
|
||||
return () => {
|
||||
abortCtrl?.abort();
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
if (roTimer) clearTimeout(roTimer);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("wheel", onWheel);
|
||||
window.removeEventListener("mousemove", onInspectMouseMove);
|
||||
window.removeEventListener("mouseup", onInspectMouseUp);
|
||||
cleanupScroll();
|
||||
unlistenFs();
|
||||
ro.disconnect();
|
||||
};
|
||||
});
|
||||
@@ -1001,6 +1097,49 @@
|
||||
<button class="icon-btn" class:active={isBookmarked} onclick={toggleBookmark} title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
|
||||
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
|
||||
</button>
|
||||
|
||||
<div class="wc-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={winOpen}
|
||||
onclick={() => { winOpen = !winOpen; markerOpen = false; zoomOpen = false; dlOpen = false; }}
|
||||
title="Window controls"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<circle cx="6" cy="1.5" r="1.2" fill="currentColor"/>
|
||||
<circle cx="6" cy="6" r="1.2" fill="currentColor"/>
|
||||
<circle cx="6" cy="10.5" r="1.2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if winOpen}
|
||||
<div class="wc-dropdown" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="wc-btn" onclick={() => { winOpen = false; win.minimize(); }}>
|
||||
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
<span>Minimize</span>
|
||||
</button>
|
||||
<button class="wc-btn" onclick={() => { winOpen = false; win.toggleMaximize(); }}>
|
||||
{#if isFullscreen}
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
{/if}
|
||||
<span>{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}</span>
|
||||
</button>
|
||||
<button class="wc-btn wc-close" onclick={() => { winOpen = false; win.close(); }}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Close</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1014,11 +1153,13 @@
|
||||
bind:this={containerEl}
|
||||
class="viewer"
|
||||
class:strip={style === "longstrip"}
|
||||
class:inspect-active={inspectScale > 1}
|
||||
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
|
||||
role="presentation"
|
||||
tabindex="-1"
|
||||
onclick={handleTap}
|
||||
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
||||
onmousedown={onInspectMouseDown}
|
||||
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
||||
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
||||
>
|
||||
|
||||
@@ -1042,13 +1183,16 @@
|
||||
<div style="height:1px;flex-shrink:0"></div>
|
||||
|
||||
{:else if style === "fade" && pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({inspectScale}) translate({inspectPanX / inspectScale}px,{inspectPanY / inspectScale}px)">
|
||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
||||
{:then src}
|
||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
{:else if style === "double" && pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({inspectScale}) translate({inspectPanX / inspectScale}px,{inspectPanY / inspectScale}px)">
|
||||
{#if pageGroups.length}
|
||||
<div class="double-wrap">
|
||||
{#each currentGroup as pg, i}
|
||||
@@ -1062,13 +1206,16 @@
|
||||
{:else}
|
||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({inspectScale}) translate({inspectPanX / inspectScale}px,{inspectPanY / inspectScale}px)">
|
||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||||
{:then src}
|
||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1081,49 +1228,46 @@
|
||||
<div
|
||||
class="slider-wrap"
|
||||
class:dragging={sliderDragging}
|
||||
role="slider"
|
||||
aria-valuenow={sliderPage}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={sliderMax}
|
||||
tabindex="-1"
|
||||
onmouseenter={() => sliderHover = true}
|
||||
onmouseleave={() => { sliderHover = false; }}
|
||||
role="presentation"
|
||||
onmouseleave={() => { sliderHover = false; sliderDragging = false; }}
|
||||
onmousedown={(e) => {
|
||||
sliderDragging = true;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
jumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
|
||||
}}
|
||||
onmousemove={(e) => {
|
||||
if (!sliderDragging) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
jumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
|
||||
}}
|
||||
onmouseup={() => sliderDragging = false}
|
||||
>
|
||||
<div class="slider-track-bg">
|
||||
<div class="slider-fill" style="width: {rtl ? 100 - sliderPct : sliderPct}%"></div>
|
||||
<div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div>
|
||||
</div>
|
||||
<div class="slider-thumb" style="left:{sliderPct}%"></div>
|
||||
|
||||
{#if isBookmarked && currentBookmark}
|
||||
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div
|
||||
class="slider-checkpoint bookmark-checkpoint"
|
||||
style="left: {rtl ? 100 - bPct : bPct}%"
|
||||
title="Bookmark: Page {currentBookmark.pageNumber}"
|
||||
></div>
|
||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||
{@const bOrd = rtl ? lastPage - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
|
||||
{@const bPct = lastPage > 1 ? ((bOrd - 1) / (lastPage - 1)) * 100 : 0}
|
||||
<div class="slider-checkpoint bookmark-checkpoint" style="left: {bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
||||
{/if}
|
||||
|
||||
{#each activeChapterMarkers as m (m.id)}
|
||||
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div
|
||||
class="slider-checkpoint marker-checkpoint"
|
||||
style="left: {rtl ? 100 - mPct : mPct}%; background:{MARKER_COLOR_HEX[m.color]}"
|
||||
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"
|
||||
></div>
|
||||
{@const mOrd = rtl ? lastPage - m.pageNumber + 1 : m.pageNumber}
|
||||
{@const mPct = lastPage > 1 ? ((mOrd - 1) / (lastPage - 1)) * 100 : 0}
|
||||
<div class="slider-checkpoint marker-checkpoint" style="left: {mPct}%; background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
||||
{/each}
|
||||
|
||||
<input
|
||||
type="range"
|
||||
class="slider-input"
|
||||
min={1}
|
||||
max={sliderMax}
|
||||
step={1}
|
||||
value={sliderPage}
|
||||
dir={rtl ? "rtl" : "ltr"}
|
||||
oninput={onSliderInput}
|
||||
onmousedown={() => sliderDragging = true}
|
||||
onmouseup={() => sliderDragging = false}
|
||||
ontouchstart={() => sliderDragging = true}
|
||||
ontouchend={() => sliderDragging = false}
|
||||
aria-label="Page {sliderPage} of {sliderMax}"
|
||||
/>
|
||||
{#if sliderHover || sliderDragging}
|
||||
<div class="slider-tooltip" style="left: {rtl ? 100 - sliderPct : sliderPct}%">
|
||||
<div class="slider-tooltip" style="left: {sliderPct}%">
|
||||
{sliderPage} / {sliderMax}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1217,6 +1361,9 @@
|
||||
.marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.marker-color-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.marker-swatch { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 4px; border-radius: var(--radius-sm); background: none; border: none; cursor: pointer; flex: 1; transition: background var(--t-fast); }
|
||||
.marker-swatch:hover { background: var(--bg-overlay); }
|
||||
.swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); box-shadow: 0 0 0 0 var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
|
||||
.marker-swatch:hover .swatch-dot { transform: scale(1.15); }
|
||||
.marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); }
|
||||
@@ -1231,9 +1378,20 @@
|
||||
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
|
||||
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
.wc-wrap { position: relative; flex-shrink: 0; }
|
||||
.wc-dropdown { position: absolute; top: calc(100% + 6px); right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); display: flex; flex-direction: column; gap: 2px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 148px; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.wc-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; width: 100%; transition: color var(--t-base), background var(--t-base); }
|
||||
.wc-btn svg { flex-shrink: 0; opacity: 0.75; }
|
||||
.wc-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
.wc-close:hover { color: #fff; background: #c0392b; }
|
||||
|
||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
||||
.viewer:focus { outline: none; }
|
||||
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
||||
.viewer.inspect-active:active { cursor: grabbing; }
|
||||
|
||||
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||
|
||||
.img { display: block; user-select: none; image-rendering: auto; }
|
||||
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
|
||||
@@ -1257,11 +1415,12 @@
|
||||
.nav-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
.slider-wrap { flex: 1; position: relative; display: flex; align-items: center; height: 34px; cursor: pointer; }
|
||||
.slider-track-bg { position: absolute; left: 0; right: 0; height: 3px; background: var(--border-strong); border-radius: 2px; overflow: hidden; pointer-events: none; }
|
||||
.slider-fill { height: 100%; background: var(--accent-fg); border-radius: 2px; transition: width 0.05s linear; }
|
||||
.slider-input { position: absolute; left: 0; right: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; margin: 0; z-index: 2; }
|
||||
.slider-track-bg { position: absolute; left: 0; right: 0; height: 3px; background: var(--border-strong); border-radius: 2px; pointer-events: none; }
|
||||
.slider-fill { height: 100%; background: var(--accent-fg); border-radius: 2px; transition: width 0.05s linear; position: relative; }
|
||||
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); pointer-events: none; z-index: 1; }
|
||||
.bookmark-checkpoint { background: var(--accent-fg); opacity: 0.7; }
|
||||
.slider-thumb { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); pointer-events: none; z-index: 2; box-shadow: 0 0 0 2px rgba(0,0,0,0.5); transition: transform var(--t-fast); }
|
||||
.slider-wrap:hover .slider-thumb, .slider-wrap.dragging .slider-thumb { transform: translate(-50%, -50%) scale(1.3); }
|
||||
.bookmark-checkpoint { background: #ffffff; opacity: 0.8; }
|
||||
.marker-checkpoint { opacity: 0.85; }
|
||||
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||||
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga } from "../../store/state.svelte";
|
||||
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga, addBookmark, acknowledgeUpdate } from "../../store/state.svelte";
|
||||
import type { MangaPrefs } from "../../store/state.svelte";
|
||||
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
@@ -98,11 +98,19 @@
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
);
|
||||
|
||||
const scanlatorFilter = $derived((getPref("scanlatorFilter") ?? []) as string[]);
|
||||
const scanlatorFilter = $derived((getPref("scanlatorFilter") ?? []) as string[]);
|
||||
const scanlatorBlacklist = $derived((getPref("scanlatorBlacklist") ?? []) as string[]);
|
||||
const scanlatorForce = $derived((getPref("scanlatorForce") ?? false) as boolean);
|
||||
|
||||
let scanTab: "prefer" | "block" = $state("prefer");
|
||||
|
||||
const sortedChapters = $derived.by(() => {
|
||||
let base = [...chapters];
|
||||
|
||||
if (scanlatorBlacklist.length > 0) {
|
||||
base = base.filter(c => !scanlatorBlacklist.includes(c.scanlator ?? ""));
|
||||
}
|
||||
|
||||
if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
||||
else if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
|
||||
else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
@@ -119,7 +127,9 @@
|
||||
for (const ch of base) {
|
||||
const existing = seen.get(ch.chapterNumber);
|
||||
if (!existing) {
|
||||
seen.set(ch.chapterNumber, ch);
|
||||
if (!scanlatorForce || scanlatorFilter.includes(ch.scanlator ?? "")) {
|
||||
seen.set(ch.chapterNumber, ch);
|
||||
}
|
||||
} else {
|
||||
const np = scanlatorFilter.indexOf(ch.scanlator ?? "");
|
||||
const op = scanlatorFilter.indexOf(existing.scanlator ?? "");
|
||||
@@ -148,13 +158,30 @@
|
||||
|
||||
const continueChapter = $derived((() => {
|
||||
if (!sortedChapters.length) return null;
|
||||
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const anyRead = asc.some(c => c.isRead);
|
||||
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const anyRead = asc.some(c => c.isRead);
|
||||
|
||||
const bookmark = store.activeManga
|
||||
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
|
||||
: null;
|
||||
if (bookmark) {
|
||||
const ch = asc.find(c => c.id === bookmark.chapterId);
|
||||
if (ch) {
|
||||
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
|
||||
const allRead = asc.every(c => c.isRead);
|
||||
// If bookmarked chapter is the last one and everything is read,
|
||||
// treat as fully finished — fall through to "reread"
|
||||
if (!(isLastChapter && allRead)) {
|
||||
return { chapter: ch, type: "continue" as const, resumePage: bookmark.pageNumber };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
||||
if (inProgress) return { chapter: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
|
||||
const firstUnread = asc.find(c => !c.isRead);
|
||||
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const };
|
||||
return { chapter: asc[0], type: "reread" as const };
|
||||
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
|
||||
return { chapter: asc[0], type: "reread" as const, resumePage: null };
|
||||
})());
|
||||
|
||||
const jumpChapter = $derived.by(() => {
|
||||
@@ -235,8 +262,11 @@
|
||||
}
|
||||
|
||||
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||
if (chaps.length) {
|
||||
const mangaStatus = manga?.status;
|
||||
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
|
||||
// Never auto-move an ONGOING series into Completed — user must do that manually.
|
||||
const isOngoing = mangaStatus === "ONGOING";
|
||||
if (chaps.length && !isOngoing) {
|
||||
const allRead = chaps.every(c => c.isRead);
|
||||
const completed = allCategories.find(c => c.name === "Completed");
|
||||
if (completed) {
|
||||
@@ -306,7 +336,7 @@
|
||||
|
||||
$effect(() => {
|
||||
const m = store.activeManga;
|
||||
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); loadCategories(m.id); });
|
||||
if (m) untrack(() => { acknowledgeUpdate(m.id); loadManga(m.id); loadChapters(m.id); loadCategories(m.id); });
|
||||
});
|
||||
|
||||
let prevChapterId: number | null = null;
|
||||
@@ -510,6 +540,10 @@
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||
const cat = res.createCategory.category;
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.activeManga.id, addTo: [cat.id], removeFrom: [] });
|
||||
if (!manga?.inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: store.activeManga.id, inLibrary: true }).catch(console.error);
|
||||
if (manga) manga = { ...manga, inLibrary: true };
|
||||
}
|
||||
allCategories = [...allCategories, cat];
|
||||
mangaCategories = [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
@@ -525,20 +559,39 @@
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
});
|
||||
if (!inCat && !manga?.inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: store.activeManga.id, inLibrary: true }).catch(console.error);
|
||||
if (manga) manga = { ...manga, inLibrary: true };
|
||||
}
|
||||
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function openReaderWithAhead(ch: Chapter, list: Chapter[]) {
|
||||
function openReaderWithAhead(ch: Chapter, list: Chapter[], type?: "start" | "continue" | "reread", resumePage?: number | null) {
|
||||
const ascList = [...list].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
|
||||
const ahead = getPref("downloadAhead");
|
||||
if (ahead > 0) {
|
||||
const idx = list.indexOf(ch);
|
||||
const idx = ascList.indexOf(ch);
|
||||
if (idx >= 0) {
|
||||
const toQueue = list.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
|
||||
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
|
||||
if (toQueue.length) enqueueMultiple(toQueue);
|
||||
}
|
||||
}
|
||||
openReader(ch, list);
|
||||
if (type === "continue" && resumePage && resumePage > 1) {
|
||||
const existing = store.bookmarks.find(b => b.chapterId === ch.id);
|
||||
if (!existing || existing.pageNumber < resumePage) {
|
||||
addBookmark({
|
||||
mangaId: store.activeManga!.id,
|
||||
mangaTitle: store.activeManga!.title,
|
||||
thumbnailUrl: store.activeManga!.thumbnailUrl,
|
||||
chapterId: ch.id,
|
||||
chapterName: ch.name,
|
||||
pageNumber: resumePage,
|
||||
});
|
||||
}
|
||||
}
|
||||
openReader(ch, ascList);
|
||||
}
|
||||
|
||||
async function openLinkPicker() {
|
||||
@@ -591,7 +644,7 @@
|
||||
{#if manga?.genre?.length}
|
||||
<div class="genres">
|
||||
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g}
|
||||
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button>
|
||||
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("search"); setActiveManga(null); }}>{g}</button>
|
||||
{/each}
|
||||
{#if manga.genre.length > 3}
|
||||
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
|
||||
@@ -608,11 +661,11 @@
|
||||
|
||||
<div class="cta-section">
|
||||
{#if continueChapter}
|
||||
<button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, sortedChapters)}>
|
||||
<button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, sortedChapters, continueChapter!.type, continueChapter!.resumePage)}>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.type === "continue"
|
||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
||||
: continueChapter.type === "reread" ? "Read again" : "Start reading"}
|
||||
{continueChapter.type === "reread" ? "Read again"
|
||||
: continueChapter.type === "start" ? "Start reading"
|
||||
: `Continue · Ch.${continueChapter.chapter.chapterNumber}${continueChapter.resumePage ? ` p.${continueChapter.resumePage}` : ""}`}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
@@ -739,33 +792,64 @@
|
||||
|
||||
{#if availableScanlators.length > 1}
|
||||
<div class="scan-filter-wrap">
|
||||
<button class="icon-btn" class:active={scanlatorFilter.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
|
||||
<Funnel size={14} weight={scanlatorFilter.length > 0 ? "fill" : "light"} />
|
||||
<button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
|
||||
<Funnel size={14} weight={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0 ? "fill" : "light"} />
|
||||
</button>
|
||||
{#if scanFilterOpen}
|
||||
<div class="scan-filter-panel" role="menu">
|
||||
<div class="scan-filter-header">
|
||||
<span class="scan-filter-heading">Scanlators</span>
|
||||
{#if scanlatorFilter.length > 0}
|
||||
<button class="scan-filter-clear" onclick={() => { setPref("scanlatorFilter", []); chapterPage = 1; }}>Clear</button>
|
||||
<div class="scan-filter-tabs">
|
||||
<button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === "prefer"} onclick={() => scanTab = "prefer"}>Prefer</button>
|
||||
<button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === "block"} onclick={() => scanTab = "block"}>Block</button>
|
||||
</div>
|
||||
{#if scanTab === "prefer" && scanlatorFilter.length > 0}
|
||||
<button class="scan-filter-clear" onclick={() => { setPref("scanlatorFilter", []); setPref("scanlatorForce", false); chapterPage = 1; }}>Clear</button>
|
||||
{:else if scanTab === "block" && scanlatorBlacklist.length > 0}
|
||||
<button class="scan-filter-clear" onclick={() => { setPref("scanlatorBlacklist", []); chapterPage = 1; }}>Clear</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="scan-filter-divider"></div>
|
||||
{#each availableScanlators as s}
|
||||
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorFilter.includes(s)} role="menuitem"
|
||||
onclick={() => {
|
||||
const next = scanlatorFilter.includes(s)
|
||||
? scanlatorFilter.filter(x => x !== s)
|
||||
: [...scanlatorFilter, s];
|
||||
setPref("scanlatorFilter", next);
|
||||
chapterPage = 1;
|
||||
}}>
|
||||
<span class="scan-filter-check" class:scan-filter-check-on={scanlatorFilter.includes(s)}>
|
||||
{#if scanlatorFilter.includes(s)}<Check size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{s}
|
||||
</button>
|
||||
{/each}
|
||||
{#if scanTab === "prefer"}
|
||||
<div class="scan-filter-force-row">
|
||||
<span class="scan-filter-force-label" title="Hide chapters with no preferred group match, rather than falling back to any available group.">Enforce</span>
|
||||
<button class="scan-force-toggle" class:scan-force-on={scanlatorForce}
|
||||
onclick={() => { setPref("scanlatorForce", !scanlatorForce); chapterPage = 1; }}>
|
||||
{scanlatorForce ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="scan-filter-divider"></div>
|
||||
{#each availableScanlators as s}
|
||||
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorFilter.includes(s)} role="menuitem"
|
||||
onclick={() => {
|
||||
const next = scanlatorFilter.includes(s)
|
||||
? scanlatorFilter.filter(x => x !== s)
|
||||
: [...scanlatorFilter, s];
|
||||
setPref("scanlatorFilter", next);
|
||||
chapterPage = 1;
|
||||
}}>
|
||||
<span class="scan-filter-check" class:scan-filter-check-on={scanlatorFilter.includes(s)}>
|
||||
{#if scanlatorFilter.includes(s)}<Check size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{s}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each availableScanlators as s}
|
||||
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorBlacklist.includes(s)} class:scan-filter-item-block={scanlatorBlacklist.includes(s)} role="menuitem"
|
||||
onclick={() => {
|
||||
const next = scanlatorBlacklist.includes(s)
|
||||
? scanlatorBlacklist.filter(x => x !== s)
|
||||
: [...scanlatorBlacklist, s];
|
||||
setPref("scanlatorBlacklist", next);
|
||||
chapterPage = 1;
|
||||
}}>
|
||||
<span class="scan-filter-check" class:scan-filter-check-block={scanlatorBlacklist.includes(s)}>
|
||||
{#if scanlatorBlacklist.includes(s)}<X size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{s}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -888,7 +972,7 @@
|
||||
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||
{@const isGridSelected = selectedIds.has(ch.id)}
|
||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
|
||||
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters)}
|
||||
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters, inProgress ? "continue" : undefined)}
|
||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
||||
title={ch.name}>
|
||||
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
||||
@@ -901,9 +985,10 @@
|
||||
{#each pageChapters as ch}
|
||||
{@const idxInSorted = sortedChapters.indexOf(ch)}
|
||||
{@const isSelected = selectedIds.has(ch.id)}
|
||||
{@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
|
||||
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters)}
|
||||
onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters))}
|
||||
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters, chInProgress ? "continue" : undefined)}
|
||||
onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters, chInProgress ? "continue" : undefined))}
|
||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
||||
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => toggleSelect(ch.id, e)} title="Select">
|
||||
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
|
||||
@@ -982,7 +1067,7 @@
|
||||
<span class="link-title">Link as same series</span>
|
||||
<button class="link-close" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
<p class="link-hint">Mark two manga as the same series so duplicates are merged in search and discover. Click a linked entry again to unlink.</p>
|
||||
<p class="link-hint">Mark two manga as the same series so duplicates are merged in search. Click a linked entry again to unlink.</p>
|
||||
<div class="link-search-wrap">
|
||||
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusOnMount />
|
||||
</div>
|
||||
@@ -1240,6 +1325,18 @@
|
||||
.scan-filter-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.scan-filter-item-active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.scan-filter-item-active:hover { background: var(--accent-dim); }
|
||||
.scan-filter-tabs { display: flex; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px; }
|
||||
.scan-filter-tab { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: 2px; border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
|
||||
.scan-filter-tab:hover { color: var(--text-muted); }
|
||||
.scan-filter-tab.scan-filter-tab-active { background: var(--bg-surface); color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||
.scan-filter-force-row { display: flex; align-items: center; justify-content: space-between; padding: 5px 10px; }
|
||||
.scan-filter-force-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); cursor: default; text-decoration: underline; text-decoration-style: dotted; text-decoration-color: var(--border-strong); text-underline-offset: 3px; }
|
||||
.scan-force-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.scan-force-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.scan-force-toggle.scan-force-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.scan-filter-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--bg-base); transition: background var(--t-base), border-color var(--t-base); }
|
||||
.scan-filter-check-on { background: var(--accent); border-color: var(--accent); }
|
||||
.scan-filter-check-block { background: var(--color-error); border-color: var(--color-error); }
|
||||
.scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; }
|
||||
.scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; }
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
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 { store, openReader, addToast, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, addBookmark } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
|
||||
let manga: Manga | null = $state(null);
|
||||
@@ -87,11 +87,38 @@
|
||||
|
||||
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" };
|
||||
const asc = [...chapters]; // already sorted by sourceOrder from load()
|
||||
const anyRead = asc.some(c => c.isRead);
|
||||
|
||||
const bookmark = displayManga
|
||||
? store.bookmarks.find(b => b.mangaId === displayManga!.id)
|
||||
: null;
|
||||
if (bookmark) {
|
||||
const ch = asc.find(c => c.id === bookmark.chapterId);
|
||||
if (ch) {
|
||||
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
|
||||
const allRead = asc.every(c => c.isRead);
|
||||
// If bookmarked chapter is the last one and everything is read,
|
||||
// treat as fully finished — fall through to "reread"
|
||||
if (!(isLastChapter && allRead)) {
|
||||
return { ch, type: "continue" as const, resumePage: bookmark.pageNumber };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
|
||||
const firstUnread = asc.find(c => !c.isRead);
|
||||
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
|
||||
return { ch: asc[0], type: "reread" as const, resumePage: null };
|
||||
});
|
||||
|
||||
const continueLabel = $derived.by(() => {
|
||||
if (!continueChapter) return "";
|
||||
const { ch, type, resumePage } = continueChapter;
|
||||
if (type === "reread") return "Read again";
|
||||
if (type === "start") return `Start · Ch.${ch.chapterNumber}`;
|
||||
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
|
||||
});
|
||||
|
||||
$effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
|
||||
@@ -187,9 +214,12 @@
|
||||
}
|
||||
|
||||
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 mangaStatus = (manga ?? displayManga)?.status;
|
||||
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
|
||||
// Sync local mangaCategories state after the mutation.
|
||||
// Never auto-move an ONGOING series into Completed — user must do that manually.
|
||||
const isOngoing = mangaStatus === "ONGOING";
|
||||
if (chaps.length && !isOngoing) {
|
||||
const allRead = chaps.every(c => c.isRead);
|
||||
const completed = allCategories.find(c => c.name === "Completed");
|
||||
if (completed) {
|
||||
@@ -209,6 +239,11 @@
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
}).catch(console.error);
|
||||
if (!inCat && !inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
|
||||
if (manga) manga = { ...manga, inLibrary: true };
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
}
|
||||
mangaCategories = inCat
|
||||
? mangaCategories.filter(c => c.id !== cat.id)
|
||||
: [...mangaCategories, cat];
|
||||
@@ -222,6 +257,11 @@
|
||||
const cat = res.createCategory.category;
|
||||
allCategories = [...allCategories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.previewManga.id, addTo: [cat.id], removeFrom: [] });
|
||||
if (!inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: store.previewManga.id, inLibrary: true }).catch(console.error);
|
||||
if (manga) manga = { ...manga, inLibrary: true };
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
}
|
||||
mangaCategories = [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
newFolderName = ""; creatingFolder = false;
|
||||
@@ -357,8 +397,25 @@
|
||||
<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 class="read-btn" onclick={() => {
|
||||
const { ch, type, resumePage } = continueChapter!;
|
||||
if (type === "continue" && resumePage && resumePage > 1) {
|
||||
const existing = store.bookmarks.find(b => b.chapterId === ch.id);
|
||||
if (!existing || existing.pageNumber < resumePage) {
|
||||
addBookmark({
|
||||
mangaId: displayManga!.id,
|
||||
mangaTitle: displayManga!.title,
|
||||
thumbnailUrl: displayManga!.thumbnailUrl,
|
||||
chapterId: ch.id,
|
||||
chapterName: ch.name,
|
||||
pageNumber: resumePage,
|
||||
});
|
||||
}
|
||||
}
|
||||
openReader(ch, chapters, displayManga);
|
||||
close();
|
||||
}}>
|
||||
<Play size={12} weight="fill" />{continueLabel}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if !loadingDetail}
|
||||
@@ -387,7 +444,7 @@
|
||||
{#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>
|
||||
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("search"); close(); }}>{g}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -423,7 +480,7 @@
|
||||
<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.
|
||||
Mark two manga as the same series so duplicates are merged in search.
|
||||
Click a linked entry again to unlink.
|
||||
</p>
|
||||
<div class="link-search-wrap">
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { type Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { children, class: cls = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Structure mirrors daisyUI hover-3d:
|
||||
- :first-child (.hover-3d-content) → the card, gets rotate3d + scale
|
||||
- :nth-child(2..9) → 8 invisible zone divs occupying the 3×3 grid
|
||||
The wrapper IS the inline-grid, zones sit on top via z-index,
|
||||
tilt is driven purely by CSS :has() — zero JS.
|
||||
-->
|
||||
<div class="hover-3d {cls}">
|
||||
<div class="hover-3d-content">
|
||||
{@render children()}
|
||||
</div>
|
||||
<!-- 8 zones: TL TC TR ML MR BL BC BR (no centre) -->
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hover-3d {
|
||||
display: inline-grid;
|
||||
perspective: 75rem;
|
||||
--transform: 0, 0;
|
||||
--shine: 100% 100%;
|
||||
--shadow: 0rem 0rem 0rem;
|
||||
--ease-out: linear(0, 0.931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);
|
||||
--ease-hover: linear(0, 0.708 15.2%, 0.927 23.6%, 1.067 33%, 1.12 41%, 1.13 50.2%, 1.019 83.2%, 1);
|
||||
filter:
|
||||
drop-shadow(var(--shadow) 0.1rem #00000020)
|
||||
drop-shadow(var(--shadow) 0.2rem #00000015)
|
||||
drop-shadow(var(--shadow) 0.3rem #00000010);
|
||||
transition: filter ease-out 400ms;
|
||||
}
|
||||
|
||||
/* Zone divs sit above the card content */
|
||||
.hover-3d > :nth-child(n + 2) {
|
||||
isolation: isolate;
|
||||
z-index: 1;
|
||||
scale: 1.2;
|
||||
}
|
||||
|
||||
/* 3×3 grid positions for the 8 zones */
|
||||
.hover-3d > :nth-child(2) { grid-area: 1/1/2/2; }
|
||||
.hover-3d > :nth-child(3) { grid-area: 1/2/2/3; }
|
||||
.hover-3d > :nth-child(4) { grid-area: 1/3/2/4; }
|
||||
.hover-3d > :nth-child(5) { grid-area: 2/1/3/2; }
|
||||
.hover-3d > :nth-child(6) { grid-area: 2/3/3/4; }
|
||||
.hover-3d > :nth-child(7) { grid-area: 3/1/4/2; }
|
||||
.hover-3d > :nth-child(8) { grid-area: 3/2/4/3; }
|
||||
.hover-3d > :nth-child(9) { grid-area: 3/3/4/4; }
|
||||
|
||||
/* The card itself */
|
||||
.hover-3d-content {
|
||||
grid-area: 1/1/4/4;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
position: relative;
|
||||
transform: rotate3d(var(--transform), 0, 10deg);
|
||||
transition:
|
||||
transform var(--ease-out) 500ms,
|
||||
scale var(--ease-out) 500ms,
|
||||
outline-color ease-out 500ms;
|
||||
outline: 0.5px solid transparent;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Shine overlay */
|
||||
.hover-3d-content::before {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
filter: blur(0.75rem);
|
||||
background-image: radial-gradient(circle at 50%, rgba(255,255,255,0.18) 10%, transparent 50%);
|
||||
translate: var(--shine);
|
||||
transition:
|
||||
translate ease-out 400ms,
|
||||
opacity ease-out 400ms;
|
||||
}
|
||||
|
||||
/* On hover: snappier ease, scale up, show shine + outline */
|
||||
.hover-3d:hover {
|
||||
--ease-out: var(--ease-hover);
|
||||
}
|
||||
.hover-3d:hover > .hover-3d-content {
|
||||
scale: 1.05;
|
||||
outline-color: rgba(255,255,255,0.07);
|
||||
}
|
||||
.hover-3d:hover > .hover-3d-content::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Zone → rotate3d + shine + shadow mappings */
|
||||
.hover-3d:has(> :nth-child(2):hover) { --transform: -1, 1; --shine: 0% 0%; --shadow: -0.5rem -0.5rem; }
|
||||
.hover-3d:has(> :nth-child(3):hover) { --transform: -1, 0; --shine: 100% 0%; --shadow: 0rem -0.5rem; }
|
||||
.hover-3d:has(> :nth-child(4):hover) { --transform: -1, -1; --shine: 200% 0%; --shadow: 0.5rem -0.5rem; }
|
||||
.hover-3d:has(> :nth-child(5):hover) { --transform: 0, 1; --shine: 0% 100%; --shadow: -0.5rem 0rem; }
|
||||
.hover-3d:has(> :nth-child(6):hover) { --transform: 0, -1; --shine: 200% 100%; --shadow: 0.5rem 0rem; }
|
||||
.hover-3d:has(> :nth-child(7):hover) { --transform: 1, 1; --shine: 0% 200%; --shadow: -0.5rem 0.5rem; }
|
||||
.hover-3d:has(> :nth-child(8):hover) { --transform: 1, 0; --shine: 100% 200%; --shadow: 0rem 0.5rem; }
|
||||
.hover-3d:has(> :nth-child(9):hover) { --transform: 1, -1; --shine: 200% 200%; --shadow: 0.5rem 0.5rem; }
|
||||
</style>
|
||||
@@ -29,7 +29,7 @@ export function fetchAuthenticated(
|
||||
return fetch(url, {
|
||||
...init,
|
||||
signal,
|
||||
credentials: "include",
|
||||
credentials: "omit",
|
||||
headers: {
|
||||
...(init.headers as Record<string, string> ?? {}),
|
||||
...(user && pass ? basicHeader(user, pass) : {}),
|
||||
@@ -37,15 +37,16 @@ export function fetchAuthenticated(
|
||||
});
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, signal });
|
||||
return fetch(url, { ...init, signal, credentials: "omit" });
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
method: "POST",
|
||||
credentials: "omit",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||
@@ -71,15 +72,13 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
credentials: "omit",
|
||||
headers,
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return "ok";
|
||||
}
|
||||
if (res.ok) return "ok";
|
||||
|
||||
if (res.status === 401) {
|
||||
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
||||
|
||||
@@ -155,7 +155,7 @@ export const CACHE_KEYS = {
|
||||
LIBRARY: "library",
|
||||
ALL_MANGA: "all_manga_unfiltered",
|
||||
CATEGORIES: "categories",
|
||||
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
|
||||
SEARCH: "search_all_manga", // Search's unfiltered fetch — separate from library
|
||||
SOURCES: "sources",
|
||||
POPULAR: "popular",
|
||||
GENRE: (genre: string) => `genre:${genre}`,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { store } from "../store/state.svelte";
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
|
||||
const MAX_CONCURRENT = 14;
|
||||
const MAX_CONCURRENT = 6;
|
||||
let active = 0;
|
||||
|
||||
interface QueueEntry {
|
||||
@@ -30,9 +30,18 @@ async function doFetch(url: string): Promise<string> {
|
||||
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() {
|
||||
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||
queue.sort((a, b) => b.priority - a.priority);
|
||||
const entry = queue.shift()!;
|
||||
active++;
|
||||
doFetch(entry.url)
|
||||
@@ -47,7 +56,7 @@ function drain() {
|
||||
|
||||
function enqueue(url: string, priority: number): Promise<string> {
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
queue.push({ url, priority, resolve, reject });
|
||||
insertSorted({ url, priority, resolve, reject });
|
||||
});
|
||||
inflight.set(url, promise);
|
||||
drain();
|
||||
@@ -62,8 +71,12 @@ export function getBlobUrl(url: string, priority = 0): Promise<string> {
|
||||
|
||||
const existing = inflight.get(url);
|
||||
if (existing) {
|
||||
const entry = queue.find(e => e.url === url);
|
||||
if (entry && priority > entry.priority) entry.priority = priority;
|
||||
const idx = queue.findIndex(e => e.url === url);
|
||||
if (idx !== -1 && priority > queue[idx].priority) {
|
||||
const [entry] = queue.splice(idx, 1);
|
||||
entry.priority = priority;
|
||||
insertSorted(entry);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
export interface Keybinds {
|
||||
pageRight: string;
|
||||
pageLeft: string;
|
||||
turnPageRight: string;
|
||||
turnPageLeft: string;
|
||||
firstPage: string;
|
||||
lastPage: string;
|
||||
chapterRight: string;
|
||||
chapterLeft: string;
|
||||
turnChapterRight: string;
|
||||
turnChapterLeft: string;
|
||||
exitReader: string;
|
||||
toggleReadingDirection: string;
|
||||
togglePageStyle: string;
|
||||
@@ -17,12 +17,12 @@ export interface Keybinds {
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
pageRight: "ArrowRight",
|
||||
pageLeft: "ArrowLeft",
|
||||
turnPageRight: "ArrowRight",
|
||||
turnPageLeft: "ArrowLeft",
|
||||
firstPage: "ctrl+ArrowLeft",
|
||||
lastPage: "ctrl+ArrowRight",
|
||||
chapterRight: "]",
|
||||
chapterLeft: "[",
|
||||
turnChapterRight: "]",
|
||||
turnChapterLeft: "[",
|
||||
exitReader: "Backspace",
|
||||
toggleReadingDirection: "d",
|
||||
togglePageStyle: "q",
|
||||
@@ -33,12 +33,12 @@ export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
};
|
||||
|
||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
pageRight: "Turn page right",
|
||||
pageLeft: "Turn page left",
|
||||
turnPageRight: "Turn page right (→)",
|
||||
turnPageLeft: "Turn page left (←)",
|
||||
firstPage: "Jump to first page",
|
||||
lastPage: "Jump to last page",
|
||||
chapterRight: "Next chapter",
|
||||
chapterLeft: "Previous chapter",
|
||||
turnChapterRight: "Turn chapter right (→)",
|
||||
turnChapterLeft: "Turn chapter left (←)",
|
||||
exitReader: "Exit reader",
|
||||
toggleReadingDirection: "Toggle reading direction",
|
||||
togglePageStyle: "Toggle page style",
|
||||
|
||||
@@ -127,6 +127,17 @@ export const UPDATE_MANGA = `
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_MANGAS = `
|
||||
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
|
||||
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
|
||||
mangas {
|
||||
id
|
||||
inLibrary
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MARK_CHAPTER_READ = `
|
||||
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||
@@ -905,3 +916,86 @@ export const REFRESH_TOKEN = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_LIBRARY = `
|
||||
mutation UpdateLibrary {
|
||||
updateLibrary(input: {}) {
|
||||
updateStatus {
|
||||
jobsInfo {
|
||||
isRunning
|
||||
finishedJobs
|
||||
totalJobs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Backup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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 LIBRARY_UPDATE_STATUS = `
|
||||
query LibraryUpdateStatus {
|
||||
libraryUpdateStatus {
|
||||
jobsInfo {
|
||||
isRunning
|
||||
finishedJobs
|
||||
totalJobs
|
||||
skippedMangasCount
|
||||
}
|
||||
mangaUpdates {
|
||||
status
|
||||
manga {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -154,6 +154,14 @@ export interface ReadingStats {
|
||||
lastStreakDate: string;
|
||||
}
|
||||
|
||||
export interface LibraryUpdateEntry {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
newChapters: number;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
const AVG_MIN_PER_CHAPTER = 5;
|
||||
|
||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||
@@ -263,6 +271,7 @@ export interface Settings {
|
||||
customThemes: CustomTheme[];
|
||||
hiddenCategoryIds: number[];
|
||||
defaultLibraryCategoryId: number | null;
|
||||
savedIsDefaultCategory: boolean;
|
||||
nsfwFilteredTags: string[];
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
@@ -334,6 +343,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
customThemes: [],
|
||||
hiddenCategoryIds: [],
|
||||
defaultLibraryCategoryId: null,
|
||||
savedIsDefaultCategory: false,
|
||||
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
||||
nsfwAllowedSourceIds: [],
|
||||
nsfwBlockedSourceIds: [],
|
||||
@@ -433,21 +443,28 @@ class Store {
|
||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
||||
discoverCache: Map<string, any> = $state(new Map());
|
||||
discoverLibraryIds: Set<number> = $state(new Set());
|
||||
discoverSrcOffset: number = $state(0);
|
||||
searchCache: Map<string, any> = $state(new Map());
|
||||
searchLibraryIds: Set<number> = $state(new Set());
|
||||
searchSrcOffset: number = $state(0);
|
||||
readerSessionId: number = $state(0);
|
||||
libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []);
|
||||
lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0);
|
||||
acknowledgedUpdates: Set<number> = $state(new Set(saved?.acknowledgedUpdateIds ?? []));
|
||||
|
||||
constructor() {
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
persist({
|
||||
settings: this.settings,
|
||||
history: this.history,
|
||||
bookmarks: this.bookmarks,
|
||||
markers: this.markers,
|
||||
readLog: this.readLog,
|
||||
readingStats: this.readingStats,
|
||||
storeVersion: STORE_VERSION,
|
||||
settings: this.settings,
|
||||
history: this.history,
|
||||
bookmarks: this.bookmarks,
|
||||
markers: this.markers,
|
||||
readLog: this.readLog,
|
||||
readingStats: this.readingStats,
|
||||
libraryUpdates: this.libraryUpdates,
|
||||
lastLibraryRefresh: this.lastLibraryRefresh,
|
||||
acknowledgedUpdateIds: [...this.acknowledgedUpdates],
|
||||
storeVersion: STORE_VERSION,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -641,8 +658,11 @@ class Store {
|
||||
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||
UPDATE_MANGA_CATEGORIES: string,
|
||||
UPDATE_MANGA?: string,
|
||||
mangaStatus?: string,
|
||||
): Promise<void> {
|
||||
if (!chaps.length) return;
|
||||
// Never auto-complete an ongoing series — user must set Completed manually.
|
||||
if (mangaStatus === "ONGOING") return;
|
||||
const allRead = chaps.every(c => c.isRead);
|
||||
const completed = categories.find(c => c.name === "Completed");
|
||||
if (!completed) return;
|
||||
@@ -662,10 +682,30 @@ class Store {
|
||||
this.settings = { ...this.settings, hiddenCategoryIds: next };
|
||||
}
|
||||
|
||||
clearDiscoverCache() {
|
||||
this.discoverCache = new Map();
|
||||
this.discoverLibraryIds = new Set();
|
||||
this.discoverSrcOffset++;
|
||||
clearSearchCache() {
|
||||
this.searchCache = new Map();
|
||||
this.searchLibraryIds = new Set();
|
||||
this.searchSrcOffset++;
|
||||
}
|
||||
|
||||
setLibraryUpdates(entries: LibraryUpdateEntry[]) {
|
||||
this.libraryUpdates = entries;
|
||||
this.lastLibraryRefresh = Date.now();
|
||||
}
|
||||
|
||||
clearLibraryUpdates() {
|
||||
this.libraryUpdates = [];
|
||||
this.lastLibraryRefresh = 0;
|
||||
this.acknowledgedUpdates = new Set();
|
||||
}
|
||||
|
||||
acknowledgeUpdate(mangaId: number) {
|
||||
if (this.acknowledgedUpdates.has(mangaId)) return;
|
||||
this.acknowledgedUpdates = new Set([...this.acknowledgedUpdates, mangaId]);
|
||||
}
|
||||
|
||||
bumpReaderSession() {
|
||||
this.readerSessionId++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,7 +738,11 @@ export function setLibraryTagFilter(next: string[]) { sto
|
||||
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||
export function resetKeybinds() { store.resetKeybinds(); }
|
||||
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
||||
export function clearSearchCache() { store.clearSearchCache(); }
|
||||
export function setLibraryUpdates(entries: LibraryUpdateEntry[]) { store.setLibraryUpdates(entries); }
|
||||
export function clearLibraryUpdates() { store.clearLibraryUpdates(); }
|
||||
export function acknowledgeUpdate(mangaId: number) { store.acknowledgeUpdate(mangaId); }
|
||||
export function bumpReaderSession() { store.bumpReaderSession(); }
|
||||
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
|
||||
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
|
||||
export function clearBookmarks() { store.clearBookmarks(); }
|
||||
@@ -720,6 +764,7 @@ export async function checkAndMarkCompleted(
|
||||
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||
UPDATE_MANGA_CATEGORIES: string,
|
||||
UPDATE_MANGA?: string,
|
||||
mangaStatus?: string,
|
||||
): Promise<void> {
|
||||
return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||
return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
|
||||
}
|
||||
|
||||