Compare commits

..

29 Commits

Author SHA1 Message Date
Youwes09 c573c54318 Chore: Flathub Support (Tinker V2) 2026-04-15 23:20:49 -05:00
Youwes09 ff5fcc4fc0 Chore: Flathub Support (Tinkering Around) 2026-04-15 18:24:46 -05:00
Youwes09 64f63ceaa2 Chore: Discover Removal Finalized 2026-04-15 00:44:03 -05:00
Youwes09 6d835914ef Fix: Re-added Marker-Swatch CSS 2026-04-14 20:55:57 -05:00
Youwes09 10f5936dbd Fix: Attempt to fix Reader Page-Misfiring Bug & Optimize Loading (Auth Only) 2026-04-14 20:54:16 -05:00
Youwes09 5ddbfdbd6d Fix: Remove Discover Tab (Not Finished) 2026-04-14 11:22:20 -05:00
Youwes09 0ff148f720 Chore: Merge Discover into Search (WIP) 2026-04-14 11:09:53 -05:00
Youwes09 d98ca76036 Fix: Optimize SplashScreen when Off-Screen 2026-04-14 10:43:12 -05:00
Youwes09 35650481b0 Chore: Update Reader Picture 2026-04-14 00:19:49 -05:00
Youwes09 8b16537c35 Chore: Update README & Pictures 2026-04-14 00:13:48 -05:00
Youwes09 96639d2152 Chore: Swap Extension Icons 2026-04-13 21:31:24 -05:00
Youwes09 1c135a79ca Feat: Enforce & Block for Scanlators 2026-04-13 21:28:57 -05:00
Youwes09 6c11a9d53e Fix: Toaster Dismissal (#27) 2026-04-13 10:59:03 -05:00
Youwes09 5a2f88b806 Fix: Settings LocalHost Auth (#25) 2026-04-13 10:55:41 -05:00
Youwes09 75430305e6 Fix: Reader CSS & TitleBar Controls + WIP Feature 2026-04-13 09:50:07 -05:00
Youwes09 ea76b5fc26 Chore: Update Tags for 0.8.0 2026-04-13 00:10:12 -05:00
Youwes09 d5d9ff8b6e Chore: Language Filter on Extensions 2026-04-13 00:07:38 -05:00
Youwes09 7c9182eb4b Feat: Backup Feature & Settings Overhaul 2026-04-12 23:40:35 -05:00
Youwes09 4d6ebe8804 Fix: Infinite Scroll MAR Threshold & Pages Loaded (Bug #24) 2026-04-12 10:59:52 -05:00
Youwes09 49562c3f76 Feat: Open in File Explorer 2026-04-11 23:04:26 -05:00
Youwes09 4a299f60ac Chore: Library Changes 2026-04-11 19:29:04 -05:00
Youwes09 de397f2462 Feat: Library Manga Updates Display 2026-04-11 19:17:44 -05:00
Youwes09 af29cffdff Feat: Check for Updates (WIP) & Toaster Design Changes 2026-04-11 09:34:22 -05:00
Youwes09 f840ae6413 Feat: Continue Again (Bookmarking-based Resume) 2026-04-10 19:53:20 -05:00
Youwes09 6b8d4fc05f Fix: Reader TitleBar Controls 2026-04-10 19:37:50 -05:00
Youwes09 15079f7755 Feat: Reader Pan + Zoom 2026-04-10 19:30:51 -05:00
Youwes09 1a08d2415f Fix: RTL Keybinds Issue & Progress Bar (Untested) 2026-04-10 19:15:05 -05:00
Youwes09 7917491389 Feat: Default Library Toggle 2026-04-06 22:53:42 -05:00
Youwes09 0b6e9fbbbb Fix: Flatpak Binary Detection 2026-04-06 20:44:34 -05:00
43 changed files with 2998 additions and 1480 deletions
+7 -7
View File
@@ -99,16 +99,16 @@ exec /usr/lib/moku/jre/bin/java \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar -jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
EOF EOF
install -Dm644 packaging/dev.moku.app.desktop \ install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \
"$pkgdir/usr/share/applications/dev.moku.app.desktop" "$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop"
install -Dm644 src-tauri/icons/32x32.png \ install -Dm644 src-tauri/icons/32x32.png \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.app.png"
install -Dm644 src-tauri/icons/128x128.png \ install -Dm644 src-tauri/icons/128x128.png \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.app.png"
install -Dm644 src-tauri/icons/128x128@2x.png \ install -Dm644 src-tauri/icons/128x128@2x.png \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png"
install -Dm644 packaging/dev.moku.app.metainfo.xml \ install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml" "$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
+6 -1
View File
@@ -21,7 +21,7 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
<div align="center"> <div align="center">
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" /> <img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
<img src="docs/screenshots/Moku-Discover.png" width="49%" alt="Discover" /> <img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="TagSearch" />
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" /> <img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" /> <img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" /> <img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
@@ -37,13 +37,18 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
## Features ## Features
- **Library management** — organize manga into folders, track unread counts, filter by genre - **Library management** — organize manga into folders, track unread counts, filter by genre
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, AZ, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds - **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
- **Extension support** — install and manage Suwayomi extensions directly from the app - **Extension support** — install and manage Suwayomi extensions directly from the app
- **Download management** — queue and monitor chapter downloads with progress toasts - **Download management** — queue and monitor chapter downloads with progress toasts
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more - **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
- **Auto-start server** — optionally launch Suwayomi in the background on startup - **Auto-start server** — optionally launch Suwayomi in the background on startup
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more - **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
- **Auto-updates** — in-app update checker with silent background notifications - **Auto-updates** — in-app update checker with silent background notifications
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
--- ---
+11 -9
View File
@@ -10,14 +10,13 @@ Minor Revisions:
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off) - Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
- Adjustment in Settings for Theme Editor: - Adjustment in Settings for Theme Editor:
- Patch Color-Picker to Work Properly - 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: Priority Bugs:
- Cache ALL Cover Pictures & Details for Manga in Library
- Fix Library Build not Updating - Fix Library Build not Updating
- Check Auth System (Only Supports Basic-Auth) - Loading Buffer for Pictures (Due to Auth Lag)
General/Misc Bugs: General/Misc Bugs:
@@ -31,13 +30,16 @@ General/Misc Bugs:
In-Progress: In-Progress:
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching) - 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: 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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 MiB

After

Width:  |  Height:  |  Size: 7.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 KiB

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 648 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 609 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 940 KiB

+3 -3
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }: perSystem = { system, lib, ... }:
let let
version = "0.7.1"; version = "0.8.0";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
@@ -177,7 +177,7 @@ EOF
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; } [[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
VERSION="$1" VERSION="$1"
REPO="$(git rev-parse --show-toplevel)" REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/dev.moku.app.yml" MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
echo " Bumping versions " echo " Bumping versions "
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \ sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
@@ -226,7 +226,7 @@ EOF
--force-clean \ --force-clean \
"$REPO/build-dir" \ "$REPO/build-dir" \
"$MANIFEST" "$MANIFEST"
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" dev.moku.app flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.Youwes09.Moku
rm -rf "$REPO/build-dir" "$REPO/repo" rm -rf "$REPO/build-dir" "$REPO/repo"
echo "moku.flatpak created" echo "moku.flatpak created"
@@ -1,4 +1,4 @@
app-id: dev.moku.app app-id: io.github.Youwes09.Moku
runtime: org.gnome.Platform runtime: org.gnome.Platform
runtime-version: '48' runtime-version: '48'
sdk: org.gnome.Sdk sdk: org.gnome.Sdk
@@ -9,16 +9,22 @@ separate-locales: false
finish-args: finish-args:
- --socket=wayland - --socket=wayland
- --socket=x11
- --socket=fallback-x11 - --socket=fallback-x11
- --share=ipc - --share=ipc
- --device=dri - --device=dri
- --share=network - --share=network
- --socket=session-bus
- --socket=system-bus - --talk-name=org.freedesktop.Notifications
- --filesystem=home - --talk-name=org.freedesktop.portal.Desktop
- --talk-name=org.freedesktop.portal.FileTransfer
- --talk-name=org.kde.StatusNotifierWatcher
- --talk-name=com.canonical.AppMenu.Registrar
- --talk-name=com.canonical.indicator.application
- --filesystem=xdg-run/discord-ipc-0:ro
- --filesystem=xdg-data/moku:create - --filesystem=xdg-data/moku:create
- --talk-name=org.freedesktop.Flatpak - --filesystem=xdg-download
build-options: build-options:
append-path: /usr/lib/sdk/rust-stable/bin append-path: /usr/lib/sdk/rust-stable/bin
@@ -33,13 +39,10 @@ modules:
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1 - tar -xf jdk.tar.gz -C /app/jre --strip-components=1
sources: sources:
- type: file - type: file
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
dest-filename: jdk.tar.gz dest-filename: jdk.tar.gz
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
# exits just that thread instead of killing the whole JVM. Official Suwayomi
# fix for headless environments. Source inlined to avoid upstream drift.
- name: catch-abort - name: catch-abort
buildsystem: simple buildsystem: simple
build-commands: build-commands:
@@ -120,7 +123,6 @@ modules:
fi fi
# Force-patch the three keys that cause JCEF/GUI crashes every launch. # Force-patch the three keys that cause JCEF/GUI crashes every launch.
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
sed -i \ sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \ -e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \ -e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
@@ -138,8 +140,6 @@ modules:
export _JAVA_OPTIONS="-Djava.awt.headless=true" export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true" export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
# that thread instead of crashing the whole JVM process.
export LD_PRELOAD="/app/lib/catch_abort.so" export LD_PRELOAD="/app/lib/catch_abort.so"
exec /app/jre/bin/java \ exec /app/jre/bin/java \
@@ -171,17 +171,19 @@ modules:
- tar -xzf frontend-dist.tar.gz - tar -xzf frontend-dist.tar.gz
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml - . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
- install -Dm755 src-tauri/target/release/moku /app/bin/moku - install -Dm755 src-tauri/target/release/moku /app/bin/moku
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop - install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png - install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png - install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.png
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png - install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.png
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml - install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml
sources: sources:
- type: dir - type: git
path: . url: https://github.com/Youwes09/Moku.git
tag: v0.8.0
commit: ff5fcc4fc0dd97e187fac15480406993bc4231da
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: d3ebde4d39e3de61420b78a9506df1a5c77c14d705e42662a45a2179bc96030e sha256: f21034da4b8da42d8084978b60e429162aabb28808fa019ffb786e877c4ae95b
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+352 -172
View File
@@ -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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -210,14 +216,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/bitflags/bitflags-2.11.0.crate", "url": "https://static.crates.io/crates/bitflags/bitflags-2.11.1.crate",
"sha256": "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af", "sha256": "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3",
"dest": "cargo/vendor/bitflags-2.11.0" "dest": "cargo/vendor/bitflags-2.11.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af\", \"files\": {}}", "contents": "{\"package\": \"c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3\", \"files\": {}}",
"dest": "cargo/vendor/bitflags-2.11.0", "dest": "cargo/vendor/bitflags-2.11.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -405,14 +411,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/cc/cc-1.2.58.crate", "url": "https://static.crates.io/crates/cc/cc-1.2.60.crate",
"sha256": "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1", "sha256": "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20",
"dest": "cargo/vendor/cc-1.2.58" "dest": "cargo/vendor/cc-1.2.60"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1\", \"files\": {}}", "contents": "{\"package\": \"43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20\", \"files\": {}}",
"dest": "cargo/vendor/cc-1.2.58", "dest": "cargo/vendor/cc-1.2.60",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -532,19 +538,6 @@
"dest": "cargo/vendor/cookie-0.18.1", "dest": "cargo/vendor/cookie-0.18.1",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -948,6 +941,19 @@
"dest": "cargo/vendor/dirs-sys-0.5.0", "dest": "cargo/vendor/dirs-sys-0.5.0",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -1185,14 +1191,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/fastrand/fastrand-2.3.0.crate", "url": "https://static.crates.io/crates/fastrand/fastrand-2.4.1.crate",
"sha256": "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be", "sha256": "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6",
"dest": "cargo/vendor/fastrand-2.3.0" "dest": "cargo/vendor/fastrand-2.4.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be\", \"files\": {}}", "contents": "{\"package\": \"9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6\", \"files\": {}}",
"dest": "cargo/vendor/fastrand-2.3.0", "dest": "cargo/vendor/fastrand-2.4.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -1299,6 +1305,19 @@
"dest": "cargo/vendor/foldhash-0.2.0", "dest": "cargo/vendor/foldhash-0.2.0",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -1325,6 +1344,19 @@
"dest": "cargo/vendor/foreign-types-macros-0.2.3", "dest": "cargo/vendor/foreign-types-macros-0.2.3",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -1822,14 +1854,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/hashbrown/hashbrown-0.16.1.crate", "url": "https://static.crates.io/crates/hashbrown/hashbrown-0.17.0.crate",
"sha256": "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100", "sha256": "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51",
"dest": "cargo/vendor/hashbrown-0.16.1" "dest": "cargo/vendor/hashbrown-0.17.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100\", \"files\": {}}", "contents": "{\"package\": \"4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51\", \"files\": {}}",
"dest": "cargo/vendor/hashbrown-0.16.1", "dest": "cargo/vendor/hashbrown-0.17.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -1965,14 +1997,27 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/hyper-rustls/hyper-rustls-0.27.7.crate", "url": "https://static.crates.io/crates/hyper-rustls/hyper-rustls-0.27.9.crate",
"sha256": "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58", "sha256": "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f",
"dest": "cargo/vendor/hyper-rustls-0.27.7" "dest": "cargo/vendor/hyper-rustls-0.27.9"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58\", \"files\": {}}", "contents": "{\"package\": \"33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f\", \"files\": {}}",
"dest": "cargo/vendor/hyper-rustls-0.27.7", "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" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -2186,14 +2231,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/indexmap/indexmap-2.13.1.crate", "url": "https://static.crates.io/crates/indexmap/indexmap-2.14.0.crate",
"sha256": "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff", "sha256": "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9",
"dest": "cargo/vendor/indexmap-2.13.1" "dest": "cargo/vendor/indexmap-2.14.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff\", \"files\": {}}", "contents": "{\"package\": \"d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9\", \"files\": {}}",
"dest": "cargo/vendor/indexmap-2.13.1", "dest": "cargo/vendor/indexmap-2.14.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -2355,14 +2400,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.94.crate", "url": "https://static.crates.io/crates/js-sys/js-sys-0.3.95.crate",
"sha256": "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9", "sha256": "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca",
"dest": "cargo/vendor/js-sys-0.3.94" "dest": "cargo/vendor/js-sys-0.3.95"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9\", \"files\": {}}", "contents": "{\"package\": \"2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca\", \"files\": {}}",
"dest": "cargo/vendor/js-sys-0.3.94", "dest": "cargo/vendor/js-sys-0.3.95",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -2459,14 +2504,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/libc/libc-0.2.184.crate", "url": "https://static.crates.io/crates/libc/libc-0.2.185.crate",
"sha256": "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af", "sha256": "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f",
"dest": "cargo/vendor/libc-0.2.184" "dest": "cargo/vendor/libc-0.2.185"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af\", \"files\": {}}", "contents": "{\"package\": \"52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f\", \"files\": {}}",
"dest": "cargo/vendor/libc-0.2.184", "dest": "cargo/vendor/libc-0.2.185",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -2485,14 +2530,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/libredox/libredox-0.1.15.crate", "url": "https://static.crates.io/crates/libredox/libredox-0.1.16.crate",
"sha256": "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08", "sha256": "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c",
"dest": "cargo/vendor/libredox-0.1.15" "dest": "cargo/vendor/libredox-0.1.16"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08\", \"files\": {}}", "contents": "{\"package\": \"e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c\", \"files\": {}}",
"dest": "cargo/vendor/libredox-0.1.15", "dest": "cargo/vendor/libredox-0.1.16",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -2729,6 +2774,19 @@
"dest": "cargo/vendor/muda-0.17.2", "dest": "cargo/vendor/muda-0.17.2",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3028,6 +3086,19 @@
"dest": "cargo/vendor/objc2-foundation-0.3.2", "dest": "cargo/vendor/objc2-foundation-0.3.2",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3132,6 +3203,32 @@
"dest": "cargo/vendor/open-5.3.3", "dest": "cargo/vendor/open-5.3.3",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3145,6 +3242,19 @@
"dest": "cargo/vendor/openssl-probe-0.2.1", "dest": "cargo/vendor/openssl-probe-0.2.1",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3525,14 +3635,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/pkg-config/pkg-config-0.3.32.crate", "url": "https://static.crates.io/crates/pkg-config/pkg-config-0.3.33.crate",
"sha256": "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c", "sha256": "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e",
"dest": "cargo/vendor/pkg-config-0.3.32" "dest": "cargo/vendor/pkg-config-0.3.33"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c\", \"files\": {}}", "contents": "{\"package\": \"19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e\", \"files\": {}}",
"dest": "cargo/vendor/pkg-config-0.3.32", "dest": "cargo/vendor/pkg-config-0.3.33",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -3876,14 +3986,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rand/rand-0.9.2.crate", "url": "https://static.crates.io/crates/rand/rand-0.9.4.crate",
"sha256": "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1", "sha256": "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea",
"dest": "cargo/vendor/rand-0.9.2" "dest": "cargo/vendor/rand-0.9.4"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1\", \"files\": {}}", "contents": "{\"package\": \"44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea\", \"files\": {}}",
"dest": "cargo/vendor/rand-0.9.2", "dest": "cargo/vendor/rand-0.9.4",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4006,14 +4116,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rayon/rayon-1.11.0.crate", "url": "https://static.crates.io/crates/rayon/rayon-1.12.0.crate",
"sha256": "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f", "sha256": "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d",
"dest": "cargo/vendor/rayon-1.11.0" "dest": "cargo/vendor/rayon-1.12.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f\", \"files\": {}}", "contents": "{\"package\": \"fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d\", \"files\": {}}",
"dest": "cargo/vendor/rayon-1.11.0", "dest": "cargo/vendor/rayon-1.12.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4045,14 +4155,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/redox_syscall/redox_syscall-0.7.3.crate", "url": "https://static.crates.io/crates/redox_syscall/redox_syscall-0.7.4.crate",
"sha256": "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16", "sha256": "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a",
"dest": "cargo/vendor/redox_syscall-0.7.3" "dest": "cargo/vendor/redox_syscall-0.7.4"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16\", \"files\": {}}", "contents": "{\"package\": \"f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a\", \"files\": {}}",
"dest": "cargo/vendor/redox_syscall-0.7.3", "dest": "cargo/vendor/redox_syscall-0.7.4",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4172,6 +4282,19 @@
"dest": "cargo/vendor/reqwest-0.13.2", "dest": "cargo/vendor/reqwest-0.13.2",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -4185,19 +4308,6 @@
"dest": "cargo/vendor/ring-0.17.14", "dest": "cargo/vendor/ring-0.17.14",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -4240,14 +4350,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustls/rustls-0.23.37.crate", "url": "https://static.crates.io/crates/rustls/rustls-0.23.38.crate",
"sha256": "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4", "sha256": "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21",
"dest": "cargo/vendor/rustls-0.23.37" "dest": "cargo/vendor/rustls-0.23.38"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4\", \"files\": {}}", "contents": "{\"package\": \"69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21\", \"files\": {}}",
"dest": "cargo/vendor/rustls-0.23.37", "dest": "cargo/vendor/rustls-0.23.38",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4305,14 +4415,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.10.crate", "url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.12.crate",
"sha256": "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef", "sha256": "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06",
"dest": "cargo/vendor/rustls-webpki-0.103.10" "dest": "cargo/vendor/rustls-webpki-0.103.12"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef\", \"files\": {}}", "contents": "{\"package\": \"8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06\", \"files\": {}}",
"dest": "cargo/vendor/rustls-webpki-0.103.10", "dest": "cargo/vendor/rustls-webpki-0.103.12",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4487,14 +4597,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/semver/semver-1.0.27.crate", "url": "https://static.crates.io/crates/semver/semver-1.0.28.crate",
"sha256": "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2", "sha256": "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd",
"dest": "cargo/vendor/semver-1.0.27" "dest": "cargo/vendor/semver-1.0.28"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2\", \"files\": {}}", "contents": "{\"package\": \"8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd\", \"files\": {}}",
"dest": "cargo/vendor/semver-1.0.27", "dest": "cargo/vendor/semver-1.0.28",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5082,6 +5192,19 @@
"dest": "cargo/vendor/sysinfo-0.32.1", "dest": "cargo/vendor/sysinfo-0.32.1",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -5241,40 +5364,58 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-drpc/tauri-plugin-drpc-0.1.6.crate", "url": "https://static.crates.io/crates/tauri-plugin-dialog/tauri-plugin-dialog-2.7.0.crate",
"sha256": "7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a", "sha256": "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809",
"dest": "cargo/vendor/tauri-plugin-drpc-0.1.6" "dest": "cargo/vendor/tauri-plugin-dialog-2.7.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a\", \"files\": {}}", "contents": "{\"package\": \"a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-drpc-0.1.6", "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" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-fs/tauri-plugin-fs-2.4.5.crate", "url": "https://static.crates.io/crates/tauri-plugin-fs/tauri-plugin-fs-2.5.0.crate",
"sha256": "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804", "sha256": "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8",
"dest": "cargo/vendor/tauri-plugin-fs-2.4.5" "dest": "cargo/vendor/tauri-plugin-fs-2.5.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804\", \"files\": {}}", "contents": "{\"package\": \"36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-fs-2.4.5", "dest": "cargo/vendor/tauri-plugin-fs-2.5.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-http/tauri-plugin-http-2.5.7.crate", "url": "https://static.crates.io/crates/tauri-plugin-http/tauri-plugin-http-2.5.8.crate",
"sha256": "d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc", "sha256": "cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d",
"dest": "cargo/vendor/tauri-plugin-http-2.5.7" "dest": "cargo/vendor/tauri-plugin-http-2.5.8"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc\", \"files\": {}}", "contents": "{\"package\": \"cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-http-2.5.7", "dest": "cargo/vendor/tauri-plugin-http-2.5.8",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5319,14 +5460,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-updater/tauri-plugin-updater-2.10.0.crate", "url": "https://static.crates.io/crates/tauri-plugin-updater/tauri-plugin-updater-2.10.1.crate",
"sha256": "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61", "sha256": "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af",
"dest": "cargo/vendor/tauri-plugin-updater-2.10.0" "dest": "cargo/vendor/tauri-plugin-updater-2.10.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61\", \"files\": {}}", "contents": "{\"package\": \"806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-updater-2.10.0", "dest": "cargo/vendor/tauri-plugin-updater-2.10.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5553,27 +5694,40 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tokio/tokio-1.50.0.crate", "url": "https://static.crates.io/crates/tokio/tokio-1.51.1.crate",
"sha256": "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d", "sha256": "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c",
"dest": "cargo/vendor/tokio-1.50.0" "dest": "cargo/vendor/tokio-1.51.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d\", \"files\": {}}", "contents": "{\"package\": \"f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c\", \"files\": {}}",
"dest": "cargo/vendor/tokio-1.50.0", "dest": "cargo/vendor/tokio-1.51.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tokio-macros/tokio-macros-2.6.1.crate", "url": "https://static.crates.io/crates/tokio-macros/tokio-macros-2.7.0.crate",
"sha256": "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c", "sha256": "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496",
"dest": "cargo/vendor/tokio-macros-2.6.1" "dest": "cargo/vendor/tokio-macros-2.7.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c\", \"files\": {}}", "contents": "{\"package\": \"385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496\", \"files\": {}}",
"dest": "cargo/vendor/tokio-macros-2.6.1", "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" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5696,14 +5850,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.10+spec-1.1.0.crate", "url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.11+spec-1.1.0.crate",
"sha256": "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b", "sha256": "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b",
"dest": "cargo/vendor/toml_edit-0.25.10+spec-1.1.0" "dest": "cargo/vendor/toml_edit-0.25.11+spec-1.1.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b\", \"files\": {}}", "contents": "{\"package\": \"0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b\", \"files\": {}}",
"dest": "cargo/vendor/toml_edit-0.25.10+spec-1.1.0", "dest": "cargo/vendor/toml_edit-0.25.11+spec-1.1.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5992,6 +6146,19 @@
"dest": "cargo/vendor/url-2.5.8", "dest": "cargo/vendor/url-2.5.8",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -6057,6 +6224,19 @@
"dest": "cargo/vendor/uuid-1.23.0", "dest": "cargo/vendor/uuid-1.23.0",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -6190,66 +6370,66 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.117.crate", "url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.118.crate",
"sha256": "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0", "sha256": "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89",
"dest": "cargo/vendor/wasm-bindgen-0.2.117" "dest": "cargo/vendor/wasm-bindgen-0.2.118"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0\", \"files\": {}}", "contents": "{\"package\": \"0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-0.2.117", "dest": "cargo/vendor/wasm-bindgen-0.2.118",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.67.crate", "url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.68.crate",
"sha256": "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e", "sha256": "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.67" "dest": "cargo/vendor/wasm-bindgen-futures-0.4.68"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e\", \"files\": {}}", "contents": "{\"package\": \"f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.67", "dest": "cargo/vendor/wasm-bindgen-futures-0.4.68",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.117.crate", "url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.118.crate",
"sha256": "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be", "sha256": "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.117" "dest": "cargo/vendor/wasm-bindgen-macro-0.2.118"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be\", \"files\": {}}", "contents": "{\"package\": \"eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.117", "dest": "cargo/vendor/wasm-bindgen-macro-0.2.118",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.117.crate", "url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.118.crate",
"sha256": "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2", "sha256": "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117" "dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.118"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2\", \"files\": {}}", "contents": "{\"package\": \"9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117", "dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.118",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.117.crate", "url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.118.crate",
"sha256": "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b", "sha256": "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.117" "dest": "cargo/vendor/wasm-bindgen-shared-0.2.118"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b\", \"files\": {}}", "contents": "{\"package\": \"5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.117", "dest": "cargo/vendor/wasm-bindgen-shared-0.2.118",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -6307,14 +6487,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.94.crate", "url": "https://static.crates.io/crates/web-sys/web-sys-0.3.95.crate",
"sha256": "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a", "sha256": "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d",
"dest": "cargo/vendor/web-sys-0.3.94" "dest": "cargo/vendor/web-sys-0.3.95"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a\", \"files\": {}}", "contents": "{\"package\": \"4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d\", \"files\": {}}",
"dest": "cargo/vendor/web-sys-0.3.94", "dest": "cargo/vendor/web-sys-0.3.95",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -7658,7 +7838,7 @@
}, },
{ {
"type": "inline", "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": "cargo",
"dest-filename": "config" "dest-filename": "config"
} }
-36
View File
@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>dev.moku.app</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>Moku</name>
<summary>Manga reader powered by Suwayomi</summary>
<description>
<p>
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
providing a clean native interface for browsing, reading, and managing your
manga library across hundreds of sources.
</p>
</description>
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
<url type="homepage">https://github.com/shozikan/Moku</url>
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
<provides>
<binary>moku</binary>
</provides>
<content_rating type="oars-1.1" />
<releases>
<release version="0.4.0" date="2025-03-22">
<description>
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
</description>
</release>
</releases>
</component>
@@ -2,7 +2,7 @@
Name=Moku Name=Moku
Comment=Manga reader powered by Suwayomi Comment=Manga reader powered by Suwayomi
Exec=moku Exec=moku
Icon=dev.moku.app Icon=io.github.Youwes09.Moku
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Graphics;Viewer; Categories=Graphics;Viewer;
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.Youwes09.Moku</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>Moku</name>
<summary>Manga reader powered by Suwayomi</summary>
<description>
<p>
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
providing a clean native interface for browsing, reading, and managing your
manga library across hundreds of sources.
</p>
<p>
Features include library management, chapter tracking, extension support,
reading history, notifications, and Discord Rich Presence integration.
</p>
</description>
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable>
<url type="homepage">https://github.com/Youwes09/Moku</url>
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image>
<caption>Home screen showing your manga library</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image>
<caption>Built-in manga reader</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image>
<caption>Discover new manga across hundreds of sources</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image>
<caption>Download manager</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image>
<caption>Settings</caption>
</screenshot>
</screenshots>
<provides>
<binary>moku</binary>
</provides>
<content_rating type="oars-1.1" />
<releases>
<release version="0.8.0" date="2025-04-01">
<description>
<p>Latest release with improved stability and UI refinements.</p>
</description>
</release>
<release version="0.4.0" date="2025-03-22">
<description>
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
</description>
</release>
</releases>
</component>
+151 -109
View File
@@ -126,9 +126,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@@ -205,7 +205,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"cairo-sys-rs", "cairo-sys-rs",
"glib", "glib",
"libc", "libc",
@@ -268,9 +268,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.59" version = "1.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -404,7 +404,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types 0.5.0", "foreign-types 0.5.0",
@@ -417,7 +417,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -678,7 +678,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.5.2", "redox_users 0.5.2",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -702,7 +702,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"block2", "block2",
"libc", "libc",
"objc2", "objc2",
@@ -861,14 +861,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.0" version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "fdeflate" name = "fdeflate"
@@ -1284,7 +1284,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-executor", "futures-executor",
@@ -1406,7 +1406,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"http", "http",
"indexmap 2.13.1", "indexmap 2.14.0",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -1430,9 +1430,9 @@ dependencies = [
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]] [[package]]
name = "heck" name = "heck"
@@ -1536,15 +1536,14 @@ dependencies = [
[[package]] [[package]]
name = "hyper-rustls" name = "hyper-rustls"
version = "0.27.7" version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [ dependencies = [
"http", "http",
"hyper", "hyper",
"hyper-util", "hyper-util",
"rustls", "rustls",
"rustls-pki-types",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
@@ -1754,12 +1753,12 @@ dependencies = [
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.1" version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.1", "hashbrown 0.17.0",
"serde", "serde",
"serde_core", "serde_core",
] ]
@@ -1883,9 +1882,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.94" version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -1921,7 +1920,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"serde", "serde",
"unicode-segmentation", "unicode-segmentation",
] ]
@@ -1934,7 +1933,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
dependencies = [ dependencies = [
"cssparser 0.29.6", "cssparser 0.29.6",
"html5ever 0.29.1", "html5ever 0.29.1",
"indexmap 2.13.1", "indexmap 2.14.0",
"selectors 0.24.0", "selectors 0.24.0",
] ]
@@ -1970,9 +1969,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.184" version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]] [[package]]
name = "libloading" name = "libloading"
@@ -1986,14 +1985,14 @@ dependencies = [
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.15" version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"libc", "libc",
"plain", "plain",
"redox_syscall 0.7.3", "redox_syscall 0.7.4",
] ]
[[package]] [[package]]
@@ -2133,7 +2132,7 @@ dependencies = [
[[package]] [[package]]
name = "moku" name = "moku"
version = "0.7.1" version = "0.8.0"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -2142,6 +2141,7 @@ dependencies = [
"sysinfo 0.32.1", "sysinfo 0.32.1",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-discord-rpc", "tauri-plugin-discord-rpc",
"tauri-plugin-http", "tauri-plugin-http",
"tauri-plugin-os", "tauri-plugin-os",
@@ -2197,7 +2197,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"jni-sys 0.3.1", "jni-sys 0.3.1",
"log", "log",
"ndk-sys", "ndk-sys",
@@ -2233,7 +2233,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"libc", "libc",
@@ -2307,7 +2307,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"block2", "block2",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
@@ -2320,7 +2320,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"objc2", "objc2",
"objc2-foundation", "objc2-foundation",
] ]
@@ -2341,7 +2341,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"dispatch2", "dispatch2",
"objc2", "objc2",
] ]
@@ -2352,7 +2352,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"dispatch2", "dispatch2",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
@@ -2385,7 +2385,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
@@ -2412,7 +2412,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"block2", "block2",
"libc", "libc",
"objc2", "objc2",
@@ -2435,7 +2435,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2446,7 +2446,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation", "objc2-foundation",
@@ -2458,7 +2458,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation",
@@ -2470,7 +2470,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"block2", "block2",
"objc2", "objc2",
"objc2-cloud-kit", "objc2-cloud-kit",
@@ -2501,7 +2501,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"block2", "block2",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -2529,11 +2529,11 @@ dependencies = [
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.76" version = "0.10.77"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"cfg-if", "cfg-if",
"foreign-types 0.3.2", "foreign-types 0.3.2",
"libc", "libc",
@@ -2561,9 +2561,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.112" version = "0.9.113"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -2600,7 +2600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.45.0",
] ]
[[package]] [[package]]
@@ -2872,9 +2872,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]] [[package]]
name = "plain" name = "plain"
@@ -2889,7 +2889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.1", "indexmap 2.14.0",
"quick-xml", "quick-xml",
"serde", "serde",
"time", "time",
@@ -2974,7 +2974,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ dependencies = [
"toml_edit 0.25.10+spec-1.1.0", "toml_edit 0.25.11+spec-1.1.0",
] ]
[[package]] [[package]]
@@ -3070,7 +3070,7 @@ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
"lru-slab", "lru-slab",
"rand 0.9.2", "rand 0.9.4",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
@@ -3144,9 +3144,9 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [ dependencies = [
"rand_chacha 0.9.0", "rand_chacha 0.9.0",
"rand_core 0.9.5", "rand_core 0.9.5",
@@ -3235,9 +3235,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.11.0" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [ dependencies = [
"either", "either",
"rayon-core", "rayon-core",
@@ -3259,16 +3259,16 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
] ]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.7.3" version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
] ]
[[package]] [[package]]
@@ -3429,6 +3429,30 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3464,18 +3488,18 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.37" version = "0.23.38"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring", "ring",
@@ -3525,7 +3549,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -3536,9 +3560,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.10" version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -3638,7 +3662,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
@@ -3679,7 +3703,7 @@ version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"cssparser 0.36.0", "cssparser 0.36.0",
"derive_more 2.1.1", "derive_more 2.1.1",
"log", "log",
@@ -3819,7 +3843,7 @@ dependencies = [
"chrono", "chrono",
"hex", "hex",
"indexmap 1.9.3", "indexmap 1.9.3",
"indexmap 2.13.1", "indexmap 2.14.0",
"schemars 0.9.0", "schemars 0.9.0",
"schemars 1.2.1", "schemars 1.2.1",
"serde_core", "serde_core",
@@ -3977,7 +4001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -4191,7 +4215,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"core-foundation 0.9.4", "core-foundation 0.9.4",
"system-configuration-sys", "system-configuration-sys",
] ]
@@ -4225,7 +4249,7 @@ version = "0.34.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"block2", "block2",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
@@ -4416,6 +4440,24 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "tauri-plugin-discord-rpc" name = "tauri-plugin-discord-rpc"
version = "0.1.0" version = "0.1.0"
@@ -4673,7 +4715,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -4795,9 +4837,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.51.0" version = "1.51.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -4870,7 +4912,7 @@ version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [ dependencies = [
"indexmap 2.13.1", "indexmap 2.14.0",
"serde_core", "serde_core",
"serde_spanned 1.1.1", "serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0", "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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [ dependencies = [
"indexmap 2.13.1", "indexmap 2.14.0",
"toml_datetime 0.6.3", "toml_datetime 0.6.3",
"winnow 0.5.40", "winnow 0.5.40",
] ]
@@ -4923,7 +4965,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [ dependencies = [
"indexmap 2.13.1", "indexmap 2.14.0",
"serde", "serde",
"serde_spanned 0.6.9", "serde_spanned 0.6.9",
"toml_datetime 0.6.3", "toml_datetime 0.6.3",
@@ -4932,11 +4974,11 @@ dependencies = [
[[package]] [[package]]
name = "toml_edit" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
dependencies = [ dependencies = [
"indexmap 2.13.1", "indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow 1.0.1", "winnow 1.0.1",
@@ -4978,7 +5020,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@@ -5279,9 +5321,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.117" version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -5292,9 +5334,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.67" version = "0.4.68"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -5302,9 +5344,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.117" version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -5312,9 +5354,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.117" version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -5325,9 +5367,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.117" version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -5349,7 +5391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indexmap 2.13.1", "indexmap 2.14.0",
"wasm-encoder", "wasm-encoder",
"wasmparser", "wasmparser",
] ]
@@ -5373,17 +5415,17 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.1",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap 2.13.1", "indexmap 2.14.0",
"semver", "semver",
] ]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.94" version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -5531,7 +5573,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -6135,7 +6177,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck 0.5.0", "heck 0.5.0",
"indexmap 2.13.1", "indexmap 2.14.0",
"prettyplease", "prettyplease",
"syn 2.0.117", "syn 2.0.117",
"wasm-metadata", "wasm-metadata",
@@ -6165,8 +6207,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags 2.11.0", "bitflags 2.11.1",
"indexmap 2.13.1", "indexmap 2.14.0",
"log", "log",
"serde", "serde",
"serde_derive", "serde_derive",
@@ -6185,7 +6227,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"id-arena", "id-arena",
"indexmap 2.13.1", "indexmap 2.14.0",
"log", "log",
"semver", "semver",
"serde", "serde",
@@ -6387,7 +6429,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [ dependencies = [
"arbitrary", "arbitrary",
"crc32fast", "crc32fast",
"indexmap 2.13.1", "indexmap 2.14.0",
"memchr", "memchr",
] ]
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.7.1" version = "0.8.0"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -20,6 +20,7 @@ tauri-plugin-shell = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-dialog = "2"
tauri-plugin-os = "2.3.2" tauri-plugin-os = "2.3.2"
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" } tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
+59 -1
View File
@@ -272,7 +272,7 @@ fn suwayomi_data_dir() -> PathBuf {
{ {
dirs::data_dir() dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"))) .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("dev.moku.app/tachidesk") .join("io.github.Youwes09.Moku.app/tachidesk")
} }
#[cfg(not(any(target_os = "windows", target_os = "macos")))] #[cfg(not(any(target_os = "windows", target_os = "macos")))]
{ {
@@ -327,6 +327,22 @@ fn resolve_server_binary(
do_log(log, "[resolve] user path not found, falling through"); do_log(log, "[resolve] user path not found, falling through");
} }
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
for name in &["tachidesk-server", "suwayomi-launcher"] {
let p = bin_dir.join(name);
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(bin_dir.to_path_buf()),
});
}
}
}
}
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
let resource_dir = { let resource_dir = {
let raw = app.path().resource_dir().unwrap_or_default(); let raw = app.path().resource_dir().unwrap_or_default();
@@ -458,11 +474,13 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
e e
})?; })?;
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
let rootdir_flag = format!( let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}", "-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy() data_dir.to_string_lossy()
); );
invocation.args.insert(0, rootdir_flag); invocation.args.insert(0, rootdir_flag);
}
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
@@ -569,11 +587,49 @@ fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env()); tauri::process::restart(&app.env());
} }
#[tauri::command]
fn open_path(path: String) -> Result<(), String> {
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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_discord_rpc::init()) .plugin(tauri_plugin_discord_rpc::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
@@ -592,6 +648,8 @@ pub fn run() {
list_releases, list_releases,
download_and_install_update, download_and_install_update,
restart_app, restart_app,
open_path,
pick_downloads_folder,
]) ])
.setup(|_app| Ok(())) .setup(|_app| Ok(()))
.on_window_event(|window, event| { .on_window_event(|window, event| {
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Moku", "productName": "Moku",
"version": "0.7.1", "version": "0.8.0",
"identifier": "dev.moku.app", "identifier": "io.github.Youwes09.Moku.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
"beforeBuildCommand": "pnpm build" "beforeBuildCommand": "pnpm build"
+24 -3
View File
@@ -4,6 +4,7 @@
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
import { gql } from "./lib/client"; import { gql } from "./lib/client";
import logoUrl from "./assets/moku-icon-splash.svg"; import logoUrl from "./assets/moku-icon-splash.svg";
import { probeServer, loginBasic, authSession, logout } from "./lib/auth"; import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
@@ -70,6 +71,7 @@
const MAX_ATTEMPTS = 10; const MAX_ATTEMPTS = 10;
const win = getCurrentWindow(); const win = getCurrentWindow();
const isWindows = platform() === "windows";
let serverProbeOk = $state(false); let serverProbeOk = $state(false);
let appReady = $state(false); let appReady = $state(false);
@@ -155,11 +157,31 @@
$effect(() => { $effect(() => {
if (!appReady) return; if (!appReady) return;
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
let paused = false;
const poll = () => {
if (paused) return;
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error); .then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
};
poll(); poll();
pollInterval = setInterval(poll, 2000); 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() { async function checkForUpdateSilently() {
@@ -452,7 +474,6 @@
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; } .content { flex: 1; overflow: hidden; }
/* Auth overlay — floats above the SplashScreen */
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; } .auth-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; } .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; } } @keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
-6
View File
@@ -6,8 +6,6 @@
import SeriesDetail from "../series/SeriesDetail.svelte"; import SeriesDetail from "../series/SeriesDetail.svelte";
import RecentActivity from "./RecentActivity.svelte"; import RecentActivity from "./RecentActivity.svelte";
import Search from "../pages/Search.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 Downloads from "../pages/Downloads.svelte";
import Extensions from "../pages/Extensions.svelte"; import Extensions from "../pages/Extensions.svelte";
import Tracking from "../pages/Tracking.svelte"; import Tracking from "../pages/Tracking.svelte";
@@ -26,10 +24,6 @@
<Search /> <Search />
{:else if store.navPage === "history"} {:else if store.navPage === "history"}
<RecentActivity /> <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"} {:else if store.navPage === "downloads"}
<Downloads /> <Downloads />
{:else if store.navPage === "extensions"} {:else if store.navPage === "extensions"}
+2 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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 { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
import type { NavPage } from "../../store/state.svelte"; import type { NavPage } from "../../store/state.svelte";
@@ -8,7 +8,6 @@
{ id: "library", label: "Library", icon: Books }, { id: "library", label: "Library", icon: Books },
{ id: "search", label: "Search", icon: MagnifyingGlass }, { id: "search", label: "Search", icon: MagnifyingGlass },
{ id: "history", label: "History", icon: ClockCounterClockwise }, { id: "history", label: "History", icon: ClockCounterClockwise },
{ id: "explore", label: "Discover", icon: Compass },
{ id: "downloads", label: "Downloads", icon: DownloadSimple }, { id: "downloads", label: "Downloads", icon: DownloadSimple },
{ id: "extensions", label: "Extensions", icon: PuzzlePiece }, { id: "extensions", label: "Extensions", icon: PuzzlePiece },
{ id: "tracking", label: "Tracking", icon: ChartLineUp }, { id: "tracking", label: "Tracking", icon: ChartLineUp },
@@ -17,8 +16,8 @@
function navigate(id: NavPage) { function navigate(id: NavPage) {
store.navPage = id; store.navPage = id;
store.activeManga = null; store.activeManga = null;
store.activeSource = null;
store.genreFilter = ""; store.genreFilter = "";
if (id !== "explore") store.activeSource = null;
} }
function goHome() { function goHome() {
+30 -2
View File
@@ -324,8 +324,10 @@
ro.observe(el); ro.observe(el);
syncSize(); syncSize();
let raf = 0, t0 = -1; let raf = 0, t0 = -1, paused = false;
function frame(now: number) { function frame(now: number) {
if (paused) { raf = 0; return; }
raf = requestAnimationFrame(frame); raf = requestAnimationFrame(frame);
if (!live) return; if (!live) return;
if (t0 < 0) t0 = now; if (t0 < 0) t0 = now;
@@ -333,8 +335,34 @@
const { cards, trigs, stamps, vignette, CW, CH, scale } = live; const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette); 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); raf = requestAnimationFrame(frame);
return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }
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();
document.removeEventListener("visibilitychange", onVisibility);
unlistenFocus.then(f => f());
};
} }
</script> </script>
+17 -11
View File
@@ -33,8 +33,11 @@
} }
$effect(() => { $effect(() => {
const activeIds = new Set(store.toasts.map(t => t.id));
store.toasts.forEach(schedule); 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> = { const icons: Record<Toast["kind"], string> = {
@@ -63,7 +66,7 @@
</span> </span>
<div class="body"> <div class="body">
<p class="title">{t.title}</p> <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>
</div> </div>
{/each} {/each}
@@ -78,22 +81,21 @@
z-index: 9999; z-index: 9999;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 5px;
pointer-events: none; pointer-events: none;
max-width: 300px;
} }
.toast { .toast {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: 10px;
padding: 10px var(--sp-3) 10px 0; padding: 12px var(--sp-3) 12px 0;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--bg-raised); background: var(--bg-raised);
border: 1px solid var(--border-dim); 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; box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
pointer-events: all; pointer-events: all;
min-width: 200px; width: 280px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
@@ -126,7 +128,7 @@
@keyframes slideOut { @keyframes slideOut {
0% { opacity: 1; transform: translateX(0) scale(1); max-height: var(--exit-h, 80px); margin-bottom: 0; } 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; } 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 { .accent-bar {
@@ -134,7 +136,7 @@
align-self: stretch; align-self: stretch;
flex-shrink: 0; flex-shrink: 0;
border-radius: 0 2px 2px 0; border-radius: 0 2px 2px 0;
margin-right: 2px; margin-right: 0;
} }
.toast-success .accent-bar { background: var(--accent-fg); } .toast-success .accent-bar { background: var(--accent-fg); }
@@ -159,7 +161,7 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 3px;
} }
.title { .title {
@@ -168,7 +170,10 @@
color: var(--text-secondary); color: var(--text-secondary);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
line-height: 1.3; line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.sub { .sub {
@@ -176,6 +181,7 @@
font-size: var(--text-2xs); font-size: var(--text-2xs);
color: var(--text-faint); color: var(--text-faint);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
line-height: 1;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
-390
View File
@@ -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>
+28 -4
View File
@@ -17,6 +17,7 @@
let refreshing = $state(false); let refreshing = $state(false);
let filter: Filter = $state("installed"); let filter: Filter = $state("installed");
let search = $state(""); let search = $state("");
let langFilter = $state<string | null>(null);
let working = $state(new Set<string>()); let working = $state(new Set<string>());
let expanded = $state(new Set<string>()); let expanded = $state(new Set<string>());
let panel: Panel = $state(null); let panel: Panel = $state(null);
@@ -100,9 +101,17 @@
const q = search.toLowerCase(); const q = search.toLowerCase();
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q); 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; 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 groups = $derived.by(() => {
const map = new Map<string, Extension[]>(); 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); } 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" }, { id: "all", label: "All" },
]; ];
function setFilter(f: Filter) { filter = f; langFilter = null; }
function toggleExpand(base: string) { function toggleExpand(base: string) {
const next = new Set(expanded); const next = new Set(expanded);
next.has(base) ? next.delete(base) : next.add(base); next.has(base) ? next.delete(base) : next.add(base);
@@ -133,7 +144,7 @@
<h1 class="heading">Extensions</h1> <h1 class="heading">Extensions</h1>
<div class="tabs"> <div class="tabs">
{#each FILTERS as f} {#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} {f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
</button> </button>
{/each} {/each}
@@ -144,10 +155,10 @@
<input class="search" placeholder="Search" bind:value={search} /> <input class="search" placeholder="Search" bind:value={search} />
</div> </div>
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos"> <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>
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL"> <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>
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo"> <button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} /> <ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
@@ -155,6 +166,15 @@
</div> </div>
</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"} {#if panel === "apk"}
<div class="ext-panel"> <div class="ext-panel">
<div class="panel-header"> <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 { 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); } .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; } .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 { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
.tab:hover { color: var(--text-muted); } .tab:hover { color: var(--text-muted); }
+32 -38
View File
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; 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 { gql, thumbUrl } from "../../lib/client";
import { getBlobUrl } from "../../lib/imageCache"; import { getBlobUrl } from "../../lib/imageCache";
import Thumbnail from "../shared/Thumbnail.svelte"; import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries"; import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; 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 { HistoryEntry } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types"; import type { Manga, Chapter, Category } from "../../lib/types";
import { buildReaderChapterList } from "../../lib/chapterList"; import { buildReaderChapterList } from "../../lib/chapterList";
@@ -36,26 +36,16 @@
let libraryManga: Manga[] = $state([]); let libraryManga: Manga[] = $state([]);
let extraManga: Manga[] = $state([]); let extraManga: Manga[] = $state([]);
let loadingLibrary: boolean = $state(true); let loadingLibrary: boolean = $state(true);
let completedCategory: Category | null = $state(null);
onMount(() => { onMount(() => {
loadLibrary(); loadLibrary();
}); });
function 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) gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
); )
const categoriesP = gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES) .then(m => { libraryManga = m; })
.then(d => d.categories.nodes.find(c => c.name === "Completed") ?? null)
.catch(() => null);
Promise.all([libraryP, categoriesP])
.then(([m, completed]) => {
libraryManga = m;
completedCategory = completed;
fetchExtraCompleted(m, completed);
})
.catch(console.error) .catch(console.error)
.finally(() => loadingLibrary = false); .finally(() => loadingLibrary = false);
} }
@@ -79,15 +69,6 @@
untrack(() => resetAndReload()); 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 continueReading = $derived((() => {
const seen = new Set<number>(); const seen = new Set<number>();
const out: HistoryEntry[] = []; const out: HistoryEntry[] = [];
@@ -254,11 +235,20 @@
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } } function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); } 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 recentHistory = $derived(store.history.slice(0, 6));
const stats = $derived(store.readingStats); 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) { function handleRowWheel(e: WheelEvent) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
@@ -450,28 +440,31 @@
<div class="bottom-row"> <div class="bottom-row">
<div class="bottom-col"> <div class="bottom-col">
<div class="bottom-section-hd"> <div class="bottom-section-hd">
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span> <span class="section-title"><Bell size={10} weight="bold" /> Updates
{#if completedManga.length > 0} {#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
<button class="see-all" onclick={() => { if (completedCategory) setLibraryFilter(String(completedCategory.id)); store.navPage = "library"; }}>View all <ArrowRight size={9} weight="bold" /></button> </span>
{#if libraryUpdates.length > 0}
<button class="see-all" onclick={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}>Clear <ArrowRight size={9} weight="bold" /></button>
{/if} {/if}
</div> </div>
{#if completedManga.length > 0} {#if libraryUpdates.length > 0}
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}> <div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
{#each completedManga as m (m.id)} {#each libraryUpdates as u (u.mangaId)}
<button class="mini-card" onclick={() => store.previewManga = m}> {@const m = libraryManga.find(x => x.id === u.mangaId)}
<button class="mini-card" onclick={() => { if (m) store.previewManga = m; }}>
<div class="mini-cover-wrap"> <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-gradient"></div>
<div class="mini-footer"> <div class="mini-footer">
<p class="mini-card-title">{m.title}</p> <p class="mini-card-title">{u.mangaTitle}</p>
{#if m.source?.displayName}<p class="mini-card-source">{m.source.displayName}</p>{/if} <p class="mini-card-source">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
</div> </div>
</div> </div>
</button> </button>
{/each} {/each}
</div> </div>
{:else} {: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} {/if}
</div> </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-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"><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-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 class="stat-card"><div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.longestStreakDays}d</span><span class="stat-label">Best streak</span></div></div>
</div> </div>
</div> </div>
@@ -619,6 +612,7 @@
.bottom-col:last-child { padding-left: var(--sp-4); } .bottom-col:last-child { padding-left: var(--sp-4); }
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); } .bottom-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; } .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 { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
.mini-row::-webkit-scrollbar { display: none; } .mini-row::-webkit-scrollbar { display: none; }
+199 -7
View File
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; 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 { 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 { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte"; import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories, setLibraryUpdates, addToast } from "../../store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "../../store/state.svelte"; import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "../../store/state.svelte";
import type { Manga, Category, Chapter } from "../../lib/types"; import type { Manga, Category, Chapter } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
import Thumbnail from "../shared/Thumbnail.svelte"; import Thumbnail from "../shared/Thumbnail.svelte";
@@ -275,6 +276,7 @@
})); }));
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks); allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
error = null; error = null;
await migrateCategorizedToLibrary();
} catch (e: any) { } catch (e: any) {
error = e.message; error = e.message;
} finally { } 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(() => { $effect(() => {
retryCount; retryCount;
loading = true; error = null; loading = true; error = null;
@@ -344,7 +355,13 @@
// 1. Pick the right base list for this tab // 1. Pick the right base list for this tab
let items: Manga[]; let items: Manga[];
if (store.libraryFilter === "library") { 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") { } else if (store.libraryFilter === "downloaded") {
items = allManga.filter(m => (m.downloadCount ?? 0) > 0); items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
} else { } else {
@@ -424,7 +441,9 @@
const counts = $derived((() => { const counts = $derived((() => {
const m: Record<string, number> = { 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, downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
}; };
for (const cat of visibleCategories) { for (const cat of visibleCategories) {
@@ -542,6 +561,11 @@
addTo: inCat ? [] : [cat.id], addTo: inCat ? [] : [cat.id],
removeFrom: 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(); await reloadCategories();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -556,6 +580,11 @@
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() }); const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
const cat = res.createCategory.category; const cat = res.createCategory.category;
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] }); 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(); await reloadCategories();
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
@@ -568,6 +597,44 @@
ctx = { x: e.clientX, y: e.clientY, manga: m }; ctx = { x: e.clientX, y: e.clientY, manga: m };
} }
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[] { function buildCtxItems(m: Manga): MenuEntry[] {
const catEntries: MenuEntry[] = visibleCategories.map(cat => { const catEntries: MenuEntry[] = visibleCategories.map(cat => {
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id); const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
@@ -579,6 +646,7 @@
}); });
return [ 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: 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) }, { label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
{ separator: true }, { separator: true },
{ label: "Select this manga", icon: CheckSquare, onClick: () => enterSelectMode(m.id) }, { label: "Select this manga", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
@@ -610,6 +678,92 @@
await reloadCategories(); 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(() => { onMount(() => {
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width); const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
ro.observe(scrollEl); ro.observe(scrollEl);
@@ -644,6 +798,7 @@
return () => { return () => {
ro.disconnect(); ro.disconnect();
unsub(); unsub();
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keydown", onKeyDown);
document.removeEventListener("mousedown", onDocMouseDown, true); document.removeEventListener("mousedown", onDocMouseDown, true);
}; };
@@ -739,7 +894,26 @@
<input class="search" placeholder="Search" bind:value={search} /> <input class="search" placeholder="Search" bind:value={search} />
</div> </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"> <div class="sort-panel-wrap">
<button <button
class="icon-btn" class="icon-btn"
@@ -843,6 +1017,14 @@
</div> </div>
</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 ───────────────────────────────────────────────── --> <!-- ── Selection toolbar ───────────────────────────────────────────────── -->
{#if selectMode} {#if selectMode}
<div class="select-bar"> <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 { 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: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); } .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) ───────────────────────────────────────────── */ /* ── Dropdown panels (shared) ───────────────────────────────────────────── */
.sort-panel-wrap, .sort-panel-wrap,
@@ -1067,5 +1252,12 @@
.error-msg { color: var(--color-error); font-size: var(--text-base); } .error-msg { color: var(--color-error); font-size: var(--text-base); }
.error-detail { color: var(--text-faint); font-size: var(--text-sm); } .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); } .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 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style> </style>
+275 -112
View File
@@ -3,11 +3,95 @@
import { gql } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte"; import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA } from "../../lib/queries"; 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 { 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"; 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 SearchTab = "keyword" | "tag" | "source";
type TagMode = "AND" | "OR"; type TagMode = "AND" | "OR";
@@ -18,7 +102,6 @@
error: string | null; error: string | null;
} }
// ── Cached manga entry for tag/source browsing ────────────────────────────
interface CachedManga { interface CachedManga {
id: number; id: number;
title: string; title: string;
@@ -27,11 +110,11 @@
status: string; status: string;
genre: string[]; genre: string[];
sourceId: string; sourceId: string;
genreEnriched: boolean; // true once fetchManga has been called for this entry genreEnriched: boolean;
} }
const CONCURRENCY = 6; const CONCURRENCY = 6;
const POPULAR_PAGES = 3; // pages to pre-fetch per source const POPULAR_PAGES = 3;
const COMMON_GENRES = [ const COMMON_GENRES = [
"Action","Adventure","Comedy","Drama","Fantasy","Romance", "Action","Adventure","Comedy","Drama","Fantasy","Romance",
@@ -49,7 +132,6 @@
{ value: "UNKNOWN", label: "Unknown" }, { value: "UNKNOWN", label: "Unknown" },
]; ];
// ── Concurrency helper ────────────────────────────────────────────────────
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> { async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
let i = 0; let i = 0;
async function worker() { async function worker() {
@@ -62,21 +144,16 @@
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); 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[] { function dedupSourcesByLang(sources: Source[], preferredLang: string): Source[] {
const map = new Map<string, Source>(); const map = new Map<string, Source>();
for (const s of sources) { for (const s of sources) {
if (s.id === "0") continue; // skip local source if (s.id === "0") continue;
const key = s.name; const key = s.name;
const existing = map.get(key); const existing = map.get(key);
if (!existing) { if (!existing) {
map.set(key, s); map.set(key, s);
continue; continue;
} }
// Prefer the preferred lang; otherwise keep alphabetically first lang
const existingIsPreferred = existing.lang === preferredLang; const existingIsPreferred = existing.lang === preferredLang;
const newIsPreferred = s.lang === preferredLang; const newIsPreferred = s.lang === preferredLang;
if (newIsPreferred && !existingIsPreferred) { if (newIsPreferred && !existingIsPreferred) {
@@ -88,15 +165,12 @@
return Array.from(map.values()); 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>(); const sourceCache = new Map<number, CachedManga>();
let sourceCacheReady = $state(false); // true once phase 1 (popular fetch) is done let sourceCacheReady = $state(false);
let sourceCacheLoading = $state(false); let sourceCacheLoading = $state(false);
let sourceCacheEnriching = $state(false); // true while background genre enrichment runs let sourceCacheEnriching = $state(false);
let sourceCacheAbort: AbortController | null = null; 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) { async function buildSourceCache(sources: Source[], signal: AbortSignal) {
const pages = [1, 2, 3]; const pages = [1, 2, 3];
const tasks: { src: Source; page: number }[] = []; const tasks: { src: Source; page: number }[] = [];
@@ -129,12 +203,10 @@
} }
} catch (e: any) { } catch (e: any) {
if (e?.name === "AbortError") return; if (e?.name === "AbortError") return;
// Individual source failures are silently skipped
} }
}, signal); }, signal);
} }
// Phase 2: background genre enrichment — only for entries with empty genre[]
async function enrichGenres(signal: AbortSignal) { async function enrichGenres(signal: AbortSignal) {
const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched); const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched);
if (!unenriched.length) return; if (!unenriched.length) return;
@@ -156,7 +228,6 @@
} }
} catch (e: any) { } catch (e: any) {
if (e?.name === "AbortError") return; if (e?.name === "AbortError") return;
// Mark as enriched anyway so we don't retry endlessly
const updated = sourceCache.get(entry.id); const updated = sourceCache.get(entry.id);
if (updated) updated.genreEnriched = true; if (updated) updated.genreEnriched = true;
} }
@@ -164,7 +235,6 @@
if (!signal.aborted) sourceCacheEnriching = false; if (!signal.aborted) sourceCacheEnriching = false;
} }
// MANGAS_BY_GENRE — local library query
const MANGAS_BY_GENRE = ` const MANGAS_BY_GENRE = `
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) { query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) { 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( function buildTagFilter(
tags: string[], tags: string[],
mode: TagMode, mode: TagMode,
@@ -202,15 +271,12 @@
return { and: [genrePart, statusPart] }; return { and: [genrePart, statusPart] };
} }
// Filter the in-memory source cache by active tags + statuses
function filterSourceCache( function filterSourceCache(
tags: string[], tags: string[],
mode: TagMode, mode: TagMode,
statuses: string[], statuses: string[],
): CachedManga[] { ): CachedManga[] {
return [...sourceCache.values()].filter((m) => { 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; if (shouldHideNsfw(m as any, store.settings)) return false;
const statusMatch = const statusMatch =
@@ -230,7 +296,6 @@
}); });
} }
// ── Global state ──────────────────────────────────────────────────────────
let tab: SearchTab = $state("keyword"); let tab: SearchTab = $state("keyword");
let preferredLang = store.settings?.preferredExtensionLang ?? "en"; let preferredLang = store.settings?.preferredExtensionLang ?? "en";
@@ -249,13 +314,12 @@
} }
}); });
// Load sources then kick off the cache build
loadingSources = true; loadingSources = true;
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => { .then((d) => {
allSources = d.sources.nodes.filter((src: Source) => src.id !== "0"); allSources = d.sources.nodes.filter((src: Source) => src.id !== "0");
// Kick off source cache build immediately after sources load
startSourceCacheBuild(); startSourceCacheBuild();
srch_start(allSources);
}) })
.catch(console.error) .catch(console.error)
.finally(() => { loadingSources = false; }); .finally(() => { loadingSources = false; });
@@ -276,7 +340,6 @@
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
sourceCacheReady = true; sourceCacheReady = true;
sourceCacheLoading = false; sourceCacheLoading = false;
// Phase 2: enrich genres in background at low priority
enrichGenres(ctrl.signal); enrichGenres(ctrl.signal);
}) })
.catch((e) => { .catch((e) => {
@@ -288,14 +351,13 @@
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort()); const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
const hasMultipleLangs = $derived(availableLangs.length > 1); const hasMultipleLangs = $derived(availableLangs.length > 1);
// ── Keyword tab ───────────────────────────────────────────────────────────
let kw_query = $state(""); let kw_query = $state("");
let kw_submitted = $state("");
let kw_results: SourceResult[] = $state([]); let kw_results: SourceResult[] = $state([]);
let kw_showAdvanced = $state(false); let kw_showAdvanced = $state(false);
let kw_selectedLangs: Set<string> = $state(new Set()); let kw_selectedLangs: Set<string> = $state(new Set());
let kw_inputEl: HTMLInputElement | null = $state(null); let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null; let kw_abortCtrl: AbortController | null = null;
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => { $effect(() => {
if (allSources.length) { if (allSources.length) {
@@ -307,7 +369,7 @@
}); });
$effect(() => { $effect(() => {
if (!loadingSources && pendingPrefill && !kw_submitted && allSources.length) { if (!loadingSources && pendingPrefill && allSources.length) {
const q = pendingPrefill; const q = pendingPrefill;
pendingPrefill = ""; pendingPrefill = "";
kw_query = q; 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[] { function kwGetVisibleSources(): Source[] {
let filtered = allSources; let filtered = allSources;
if (kw_selectedLangs.size > 0) if (kw_selectedLangs.size > 0)
@@ -332,7 +408,6 @@
kw_abortCtrl?.abort(); kw_abortCtrl?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
kw_abortCtrl = ctrl; 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) => { await runConcurrent(visible, async (src) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
@@ -364,14 +439,23 @@
const kw_visibleCount = $derived(kwGetVisibleSources().length); const kw_visibleCount = $derived(kwGetVisibleSources().length);
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0)); 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_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_activeTags: string[] = $state([]);
let tag_activeStatuses: string[] = $state([]); let tag_activeStatuses: string[] = $state([]);
let tag_tagMode: TagMode = $state("AND"); let tag_tagMode: TagMode = $state("AND");
let tag_tagFilter = $state(""); let tag_tagFilter = $state("");
// Local library results
let tag_localResults: Manga[] = $state([]); let tag_localResults: Manga[] = $state([]);
let tag_totalCount = $state(0); let tag_totalCount = $state(0);
let tag_loadingLocal = $state(false); let tag_loadingLocal = $state(false);
@@ -380,10 +464,13 @@
let tag_localHasNext = $state(false); let tag_localHasNext = $state(false);
let tag_abortLocal: AbortController | null = null; let tag_abortLocal: AbortController | null = null;
// Source cache results (filtered client-side from sourceCache)
let tag_searchSources = $state(false); let tag_searchSources = $state(false);
let tag_sourceFiltered: CachedManga[] = $state([]); 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 tag_filteredGenres = $derived.by(() => {
const q = tag_tagFilter.trim().toLowerCase(); const q = tag_tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES; 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); const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
// Local library fetch — triggered when tags or statuses change
$effect(() => { $effect(() => {
const _tags = tag_activeTags; const _tags = tag_activeTags;
const _mode = tag_tagMode; const _mode = tag_tagMode;
@@ -399,7 +485,6 @@
untrack(() => tagFetchLocal(_tags, _mode, _statuses)); untrack(() => tagFetchLocal(_tags, _mode, _statuses));
}); });
// Source cache filter — reactive to filters + cache readiness
$effect(() => { $effect(() => {
const _tags = tag_activeTags; const _tags = tag_activeTags;
const _mode = tag_tagMode; 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); let tag_autoSearchFired = $state(false);
$effect(() => { $effect(() => {
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) { if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
@@ -498,13 +649,13 @@
tag_searchSources = !tag_searchSources; 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_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
const tag_mergedResults = $derived.by(() => { const tag_mergedResults = $derived.by(() => {
const localMapped = tag_localResults; const localMapped = tag_localResults;
const sourceMapped: Manga[] = tag_sourceFiltered const fanOutMapped = tag_sourceFanOut.filter(m => !tag_localIds.has(m.id));
.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) => ({ .map((m) => ({
id: m.id, id: m.id,
title: m.title, title: m.title,
@@ -514,14 +665,13 @@
status: m.status, status: m.status,
} as Manga)); } as Manga));
return dedupeMangaByTitle( return dedupeMangaByTitle(
dedupeMangaById([...localMapped, ...sourceMapped]), dedupeMangaById([...localMapped, ...fanOutMapped, ...cacheMapped]),
store.settings.mangaLinks, store.settings.mangaLinks,
); );
}); });
const tag_totalVisible = $derived(tag_mergedResults.length); const tag_totalVisible = $derived(tag_mergedResults.length);
// ── Source browse tab ─────────────────────────────────────────────────────
let src_selectedLang = $state(preferredLang || "all"); let src_selectedLang = $state(preferredLang || "all");
let src_activeSource: Source | null = $state(null); let src_activeSource: Source | null = $state(null);
let src_browseResults: Manga[] = $state([]); 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 src_visibleSources = $derived.by(() => {
const hide = (s: Source) => shouldHideSource(s, store.settings); const hide = (s: Source) => shouldHideSource(s, store.settings);
if (src_selectedLang !== "all") { if (src_selectedLang !== "all") {
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s)); return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
} }
// Dedup by name, prefer preferredLang
const map = new Map<string, Source>(); const map = new Map<string, Source>();
for (const s of allSources) { for (const s of allSources) {
if (hide(s)) continue; if (hide(s)) continue;
@@ -603,9 +751,12 @@
onDestroy(() => { onDestroy(() => {
kw_abortCtrl?.abort(); kw_abortCtrl?.abort();
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
tag_abortLocal?.abort(); tag_abortLocal?.abort();
tag_fanOutAbort?.abort();
src_abortCtrl?.abort(); src_abortCtrl?.abort();
sourceCacheAbort?.abort(); sourceCacheAbort?.abort();
srch_abortCtrl?.abort();
}); });
</script> </script>
@@ -646,10 +797,14 @@
bind:value={kw_query} bind:value={kw_query}
class="searchInput" class="searchInput"
placeholder="Search across sources…" placeholder="Search across sources…"
onkeydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)} use:focusOnMount
/> />
{#if kw_query} {#if kw_anyLoading}
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; kw_inputEl?.focus(); }}>×</button> <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}
{#if hasMultipleLangs} {#if hasMultipleLangs}
<button <button
@@ -663,15 +818,6 @@
</svg> </svg>
</button> </button>
{/if} {/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> </div>
{#if hasMultipleLangs && kw_showAdvanced} {#if hasMultipleLangs && kw_showAdvanced}
@@ -698,7 +844,38 @@
{/if} {/if}
</div> </div>
{#if !kw_submitted} {#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}
</div>
{:else}
<div class="empty"> <div class="empty">
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true"> <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"/> <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"/>
@@ -711,70 +888,45 @@
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""}
{/if} {/if}
</p> </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> </div>
{/if}
{:else} {:else}
<div class="results"> {#if kw_flatResults.length > 0}
{#if kw_results.length === 0} <div class="searchHeader">
<div class="empty"> <span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
<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> </div>
{/if} <div class="searchGrid">
{#each kw_flatResults.slice(0, store.settings.renderLimit ?? 48) as m (m.id)}
{#each kw_results.filter((r) => r.mangas.length > 0 || r.loading || r.error) as { source, mangas, loading, error } (source.id)} <button class="srchCard" onclick={() => setPreviewManga(m)}>
<div class="sourceSection"> <div class="srchCoverWrap">
<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" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
<div class="srchGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if} {#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> </div>
<p class="cardTitle">{m.title}</p>
</button> </button>
{/each} {/each}
</div> {#if kw_anyLoading}
{#each Array(6) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div></div>
{/each}
{/if} {/if}
</div> </div>
{:else if kw_anyLoading}
<div class="searchGrid">
{#each Array(12) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div></div>
{/each} {/each}
</div>
{#if kw_allDone && !kw_hasResults} {:else if kw_allDone && !kw_hasResults}
<div class="empty"> <div class="empty">
<p class="emptyText">No results for "{kw_submitted}"</p> <p class="emptyText">No results for "{kw_query.trim()}"</p>
<p class="emptyHint">Try a different spelling or fewer words</p> <p class="emptyHint">Try a different spelling or fewer words</p>
</div> </div>
{/if} {/if}
</div>
{/if} {/if}
{:else if tab === "tag"} {:else if tab === "tag"}
@@ -849,7 +1001,7 @@
disabled={!sourceCacheReady && !sourceCacheLoading} disabled={!sourceCacheReady && !sourceCacheLoading}
onclick={tagToggleSearchSources} 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"> <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"/> <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> </svg>
@@ -1105,8 +1257,6 @@
.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); } .langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
.advancedDivider { height: 1px; background: var(--border-dim); margin: 2px 0; } .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); } .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); } .empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
.emptyIcon { color: var(--text-faint); } .emptyIcon { color: var(--text-faint); }
.emptyText { font-size: var(--text-base); color: var(--text-muted); } .emptyText { font-size: var(--text-base); color: var(--text-muted); }
@@ -1191,6 +1341,19 @@
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); } .langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); } .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; } .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> </style>
<script module> <script module>
+236 -77
View File
@@ -14,10 +14,11 @@
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte"; import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds"; import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
import { setReading } from "../../lib/discord"; import { setReading } from "../../lib/discord";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { FitMode, MarkerColor } from "../../store/state.svelte"; import type { FitMode, MarkerColor } from "../../store/state.svelte";
const AVG_MIN_PER_PAGE = 0.33; 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_STEP = 0.05;
const ZOOM_MIN = 0.1; const ZOOM_MIN = 0.1;
const ZOOM_MAX = 1.0; const ZOOM_MAX = 1.0;
@@ -37,6 +38,8 @@
const pageCache = new Map<number, string[]>(); const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>(); const inflight = new Map<number, Promise<string[]>>();
const win = getCurrentWindow();
const useBlob = $derived((appStore.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH"); const useBlob = $derived((appStore.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
function resolveUrl(url: string, priority = 0): Promise<string> { function resolveUrl(url: string, priority = 0): Promise<string> {
@@ -123,6 +126,8 @@
let error: string | null = $state(null); let error: string | null = $state(null);
let dlOpen = $state(false); let dlOpen = $state(false);
let zoomOpen = $state(false); let zoomOpen = $state(false);
let winOpen = $state(false);
let isFullscreen = $state(false);
let uiVisible = $state(true); let uiVisible = $state(true);
let pageReady = $state(false); let pageReady = $state(false);
let pageGroups: number[][] = $state([]); let pageGroups: number[][] = $state([]);
@@ -135,6 +140,7 @@
let appending = false; let appending = false;
let abortCtrl: AbortController | null = null; let abortCtrl: AbortController | null = null;
let hasNavigated = false; let hasNavigated = false;
let startAtLastPage = false;
let resumePage = $state(0); let resumePage = $state(0);
let resumeDismissed = $state(false); let resumeDismissed = $state(false);
let resumeTimer: ReturnType<typeof setTimeout> | null = null; let resumeTimer: ReturnType<typeof setTimeout> | null = null;
@@ -159,6 +165,16 @@
let sliderDragging = $state(false); let sliderDragging = $state(false);
let sliderHover = $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 rtl = $derived(store.settings.readingDirection === "rtl");
const fit = $derived((store.settings.fitMode ?? "width") as FitMode); const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
const style = $derived((store.settings.pageStyle ?? "single") as PageStyle); const style = $derived((store.settings.pageStyle ?? "single") as PageStyle);
@@ -175,8 +191,8 @@
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter) ? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
: store.activeChapter : store.activeChapter
); );
const currentBookmark = $derived(displayChapter ? store.bookmarks.find(b => b.chapterId === displayChapter!.id) : undefined); const currentBookmark = $derived(store.activeManga ? store.bookmarks.find(b => b.mangaId === store.activeManga!.id) : undefined);
const isBookmarked = $derived(!!currentBookmark && currentBookmark.pageNumber === store.pageNumber); const isBookmarked = $derived(!!currentBookmark && currentBookmark.chapterId === displayChapter?.id && currentBookmark.pageNumber === store.pageNumber);
const currentPageMarkers = $derived( const currentPageMarkers = $derived(
displayChapter ? store.getMarkersForPage(displayChapter.id, store.pageNumber) : [] displayChapter ? store.getMarkersForPage(displayChapter.id, store.pageNumber) : []
@@ -230,11 +246,12 @@
: [] : []
); );
const currentGroup = $derived( const currentGroup = $derived.by(() => {
style === "double" && pageGroups.length const group = style === "double" && pageGroups.length
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber]) ? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
: [store.pageNumber] : [store.pageNumber];
); return rtl ? [...group].reverse() : group;
});
const sliderPage = $derived.by(() => { const sliderPage = $derived.by(() => {
if (style === "double" && pageGroups.length) { if (style === "double" && pageGroups.length) {
@@ -249,7 +266,8 @@
return lastPage || 1; 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(() => { $effect(() => {
const chapter = displayChapter; const chapter = displayChapter;
@@ -268,6 +286,8 @@
abortCtrl = ctrl; abortCtrl = ctrl;
hasNavigated = false; hasNavigated = false;
appending = false; appending = false;
const goToLast = startAtLastPage;
startAtLastPage = false;
markedRead = new Set(); markedRead = new Set();
loading = true; loading = true;
error = null; error = null;
@@ -292,7 +312,8 @@
const urls = await fetchPages(id, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0); const urls = await fetchPages(id, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
store.pageUrls = urls; 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; pageReady = true;
loading = false; loading = false;
if (adjacent.next) fetchPages(adjacent.next.id).catch(() => {}); 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(() => { $effect(() => {
const chId = visibleChapterId; const chId = visibleChapterId;
@@ -472,7 +493,7 @@
while (i <= snap.length) { while (i <= snap.length) {
const a = aspects[i - 1]; const a = aspects[i - 1];
if (a > 1.2 || i === snap.length) { groups.push([i++]); } 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; pageGroups = groups;
}); });
@@ -517,6 +538,8 @@
if (style === "longstrip" && visibleChapterId && chapterId !== visibleChapterId) return; if (style === "longstrip" && visibleChapterId && chapterId !== visibleChapterId) return;
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, readAt: Date.now() }); addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, readAt: Date.now() });
if (autoBookmark) { 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 }); addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
} }
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId); if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
@@ -599,7 +622,7 @@
else closeReader(); else closeReader();
} else { } else {
if (gi > 0) store.pageNumber = pageGroups[gi - 1][0]; 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() { function goBack() {
if (loading) return; if (loading) return;
if (style === "longstrip") { if (style === "longstrip") {
if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList); if (adjacent.prev) { startAtLastPage = true; openReader(adjacent.prev, store.activeChapterList); }
return; return;
} }
if (style === "double" && pageGroups.length) { advanceGroup(false); return; } if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
@@ -639,7 +662,7 @@
if (store.pageNumber > 1) { if (store.pageNumber > 1) {
if (style === "fade") { animateFade(() => { store.pageNumber--; }); } if (style === "fade") { animateFade(() => { store.pageNumber--; }); }
else { 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); const goNext = $derived(rtl ? goBack : goForward);
@@ -684,6 +707,8 @@
removeBookmark(ch.id); removeBookmark(ch.id);
resumeVisible = false; resumeVisible = false;
} else { } 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 }); 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; markerOpen = !markerOpen;
zoomOpen = false; zoomOpen = false;
dlOpen = false; dlOpen = false;
winOpen = false;
} }
function commitMarker() { function commitMarker() {
@@ -750,61 +776,92 @@
function showUi() { function showUi() {
uiVisible = true; uiVisible = true;
if (hideTimer) clearTimeout(hideTimer); if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => { if (!markerOpen) uiVisible = false; }, 3000); hideTimer = setTimeout(() => { if (!markerOpen && !winOpen) uiVisible = false; }, 3000);
} }
$effect(() => { $effect(() => {
if (markerOpen) { if (markerOpen || winOpen) {
uiVisible = true; uiVisible = true;
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
} }
}); });
function onWheel(e: WheelEvent) { const INSPECT_ZOOM_STEP = 0.15;
if (!e.ctrlKey) return; const INSPECT_ZOOM_MAX = 8;
e.preventDefault();
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP); 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) { function clampInspectPan(scale: number, px: number, py: number): [number, number] {
jumpToPage(Number((e.currentTarget as HTMLInputElement).value)); 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) { function onKey(e: KeyboardEvent) {
if ((e.target as HTMLElement).tagName === "INPUT" || (e.target as HTMLElement).tagName === "TEXTAREA") return; if ((e.target as HTMLElement).tagName === "INPUT" || (e.target as HTMLElement).tagName === "TEXTAREA") return;
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS; const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
const r = store.settings.readingDirection === "rtl";
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
if (markerOpen) { markerOpen = false; return; } if (markerOpen) { markerOpen = false; return; }
if (zoomOpen) { zoomOpen = false; return; } if (zoomOpen) { zoomOpen = false; return; }
if (dlOpen) { dlOpen = false; return; } if (dlOpen) { dlOpen = false; return; }
if (winOpen) { winOpen = false; return; }
closeReader(); return; closeReader(); return;
} }
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); adjustZoom(ZOOM_STEP * 2); 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 === "-") { e.preventDefault(); adjustZoom(-ZOOM_STEP * 2); return; }
if (e.ctrlKey && e.key === "0") { e.preventDefault(); resetZoom(); return; } if (e.ctrlKey && e.key === "0") { e.preventDefault(); resetZoom(); return; }
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); } if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); } else if (matchesKeybind(e, kb.turnPageRight)) { e.preventDefault(); goNext(); }
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); } 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.firstPage)) { e.preventDefault(); store.pageNumber = 1; }
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); store.pageNumber = lastPage; } 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(); e.preventDefault();
const list = store.activeChapterList; const ch = rtl ? adjacent.prev : adjacent.next;
const idx = list.findIndex(c => c.id === store.activeChapter?.id); if (ch) { maybeMarkCurrentRead(); openReader(ch, store.activeChapterList); }
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
} }
else if (matchesKeybind(e, kb.chapterLeft)) { else if (matchesKeybind(e, kb.turnChapterLeft)) {
e.preventDefault(); e.preventDefault();
const list = store.activeChapterList; const ch = rtl ? adjacent.next : adjacent.prev;
const idx = list.findIndex(c => c.id === store.activeChapter?.id); if (ch) openReader(ch, store.activeChapterList);
const prev = idx > 0 ? list[idx - 1] : null;
if (prev) openReader(prev, list);
} }
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); } else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: 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.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); } else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); toggleBookmark(); } else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); toggleBookmark(); }
@@ -813,9 +870,34 @@
function handleTap(e: MouseEvent) { function handleTap(e: MouseEvent) {
if (style === "longstrip") return; if (style === "longstrip") return;
if (inspectDragMoved) { inspectDragMoved = false; return; }
const x = e.clientX / window.innerWidth; const x = e.clientX / window.innerWidth;
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); } if (x > 0.6) goNext(); else if (x < 0.4) goPrev();
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); } }
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>) { async function runDl(fn: () => Promise<unknown>) {
@@ -824,21 +906,35 @@
dlBusy = false; dlOpen = false; dlBusy = false; dlOpen = false;
} }
onMount(() => { onMount(async () => {
showUi(); showUi();
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
window.addEventListener("wheel", onWheel, { passive: false }); window.addEventListener("wheel", onWheel, { passive: false });
window.addEventListener("mousemove", onInspectMouseMove);
window.addEventListener("mouseup", onInspectMouseUp);
containerEl?.focus({ preventScroll: true }); 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); ro.observe(containerEl);
return () => { return () => {
abortCtrl?.abort(); abortCtrl?.abort();
if (hideTimer) clearTimeout(hideTimer); if (hideTimer) clearTimeout(hideTimer);
if (roTimer) clearTimeout(roTimer);
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
window.removeEventListener("wheel", onWheel); window.removeEventListener("wheel", onWheel);
window.removeEventListener("mousemove", onInspectMouseMove);
window.removeEventListener("mouseup", onInspectMouseUp);
cleanupScroll(); cleanupScroll();
unlistenFs();
ro.disconnect(); ro.disconnect();
}; };
}); });
@@ -1001,6 +1097,49 @@
<button class="icon-btn" class:active={isBookmarked} onclick={toggleBookmark} title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}> <button class="icon-btn" class:active={isBookmarked} onclick={toggleBookmark} title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} /> <Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
</button> </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>
</div> </div>
@@ -1014,11 +1153,13 @@
bind:this={containerEl} bind:this={containerEl}
class="viewer" class="viewer"
class:strip={style === "longstrip"} class:strip={style === "longstrip"}
class:inspect-active={inspectScale > 1}
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""} style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
role="presentation" role="presentation"
tabindex="-1" tabindex="-1"
onclick={handleTap} 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" }); } }} 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> <div style="height:1px;flex-shrink:0"></div>
{:else if style === "fade" && pageReady} {: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)} {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" /> <img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
{:then src} {:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" /> <img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
{/await} {/await}
</div>
{:else if style === "double" && pageReady} {:else if style === "double" && pageReady}
<div class="inspect-wrap" style="transform:scale({inspectScale}) translate({inspectPanX / inspectScale}px,{inspectPanY / inspectScale}px)">
{#if pageGroups.length} {#if pageGroups.length}
<div class="double-wrap"> <div class="double-wrap">
{#each currentGroup as pg, i} {#each currentGroup as pg, i}
@@ -1062,13 +1206,16 @@
{:else} {:else}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div> <div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{/if} {/if}
</div>
{:else if pageReady} {: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)} {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" /> <img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{:then src} {:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" /> <img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{/await} {/await}
</div>
{/if} {/if}
</div> </div>
@@ -1081,49 +1228,46 @@
<div <div
class="slider-wrap" class="slider-wrap"
class:dragging={sliderDragging} class:dragging={sliderDragging}
role="slider"
aria-valuenow={sliderPage}
aria-valuemin={1}
aria-valuemax={sliderMax}
tabindex="-1"
onmouseenter={() => sliderHover = true} onmouseenter={() => sliderHover = true}
onmouseleave={() => { sliderHover = false; }} onmouseleave={() => { sliderHover = false; sliderDragging = false; }}
role="presentation" 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-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>
<div class="slider-thumb" style="left:{sliderPct}%"></div>
{#if isBookmarked && currentBookmark} {#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0} {@const bOrd = rtl ? lastPage - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
<div {@const bPct = lastPage > 1 ? ((bOrd - 1) / (lastPage - 1)) * 100 : 0}
class="slider-checkpoint bookmark-checkpoint" <div class="slider-checkpoint bookmark-checkpoint" style="left: {bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
style="left: {rtl ? 100 - bPct : bPct}%"
title="Bookmark: Page {currentBookmark.pageNumber}"
></div>
{/if} {/if}
{#each activeChapterMarkers as m (m.id)} {#each activeChapterMarkers as m (m.id)}
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0} {@const mOrd = rtl ? lastPage - m.pageNumber + 1 : m.pageNumber}
<div {@const mPct = lastPage > 1 ? ((mOrd - 1) / (lastPage - 1)) * 100 : 0}
class="slider-checkpoint marker-checkpoint" <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>
style="left: {rtl ? 100 - mPct : mPct}%; background:{MARKER_COLOR_HEX[m.color]}"
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"
></div>
{/each} {/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} {#if sliderHover || sliderDragging}
<div class="slider-tooltip" style="left: {rtl ? 100 - sliderPct : sliderPct}%"> <div class="slider-tooltip" style="left: {sliderPct}%">
{sliderPage} / {sliderMax} {sliderPage} / {sliderMax}
</div> </div>
{/if} {/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-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-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-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; } .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: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); } .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 { 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); } .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 { 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.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; } .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 { display: block; user-select: none; image-rendering: auto; }
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; } .img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
@@ -1257,11 +1415,12 @@
.nav-btn:disabled { opacity: 0.25; cursor: default; } .nav-btn:disabled { opacity: 0.25; cursor: default; }
.slider-wrap { flex: 1; position: relative; display: flex; align-items: center; height: 34px; cursor: pointer; } .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-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; } .slider-fill { height: 100%; background: var(--accent-fg); border-radius: 2px; transition: width 0.05s linear; position: relative; }
.slider-input { position: absolute; left: 0; right: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; margin: 0; z-index: 2; }
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); pointer-events: none; z-index: 1; } .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; } .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-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; } .slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
+122 -25
View File
@@ -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 { 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 { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; 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 type { MangaPrefs } from "../../store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte"; import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types"; import type { Manga, Chapter, Category } from "../../lib/types";
@@ -99,10 +99,18 @@
); );
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(() => { const sortedChapters = $derived.by(() => {
let base = [...chapters]; 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); 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 if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
else base.sort((a, b) => a.sourceOrder - b.sourceOrder); else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
@@ -119,7 +127,9 @@
for (const ch of base) { for (const ch of base) {
const existing = seen.get(ch.chapterNumber); const existing = seen.get(ch.chapterNumber);
if (!existing) { if (!existing) {
if (!scanlatorForce || scanlatorFilter.includes(ch.scanlator ?? "")) {
seen.set(ch.chapterNumber, ch); seen.set(ch.chapterNumber, ch);
}
} else { } else {
const np = scanlatorFilter.indexOf(ch.scanlator ?? ""); const np = scanlatorFilter.indexOf(ch.scanlator ?? "");
const op = scanlatorFilter.indexOf(existing.scanlator ?? ""); const op = scanlatorFilter.indexOf(existing.scanlator ?? "");
@@ -150,11 +160,28 @@
if (!sortedChapters.length) return null; if (!sortedChapters.length) return null;
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder); const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some(c => c.isRead); 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); 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); const firstUnread = asc.find(c => !c.isRead);
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const }; if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
return { chapter: asc[0], type: "reread" as const }; return { chapter: asc[0], type: "reread" as const, resumePage: null };
})()); })());
const jumpChapter = $derived.by(() => { const jumpChapter = $derived.by(() => {
@@ -235,8 +262,11 @@
} }
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) { async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA); const mangaStatus = manga?.status;
if (chaps.length) { 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 allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed"); const completed = allCategories.find(c => c.name === "Completed");
if (completed) { if (completed) {
@@ -306,7 +336,7 @@
$effect(() => { $effect(() => {
const m = store.activeManga; 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; let prevChapterId: number | null = null;
@@ -510,6 +540,10 @@
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name }); const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
const cat = res.createCategory.category; const cat = res.createCategory.category;
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.activeManga.id, addTo: [cat.id], removeFrom: [] }); 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]; allCategories = [...allCategories, cat];
mangaCategories = [...mangaCategories, cat]; mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
@@ -525,20 +559,39 @@
addTo: inCat ? [] : [cat.id], addTo: inCat ? [] : [cat.id],
removeFrom: 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]; mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
} catch (e) { console.error(e); } } 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"); const ahead = getPref("downloadAhead");
if (ahead > 0) { if (ahead > 0) {
const idx = list.indexOf(ch); const idx = ascList.indexOf(ch);
if (idx >= 0) { 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); 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() { async function openLinkPicker() {
@@ -591,7 +644,7 @@
{#if manga?.genre?.length} {#if manga?.genre?.length}
<div class="genres"> <div class="genres">
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g} {#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g}
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button> <button class="genre" onclick={() => { setGenreFilter(g); setNavPage("search"); setActiveManga(null); }}>{g}</button>
{/each} {/each}
{#if manga.genre.length > 3} {#if manga.genre.length > 3}
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}> <button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
@@ -608,11 +661,11 @@
<div class="cta-section"> <div class="cta-section">
{#if continueChapter} {#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" /> <Play size={12} weight="fill" />
{continueChapter.type === "continue" {continueChapter.type === "reread" ? "Read again"
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}` : continueChapter.type === "start" ? "Start reading"
: continueChapter.type === "reread" ? "Read again" : "Start reading"} : `Continue · Ch.${continueChapter.chapter.chapterNumber}${continueChapter.resumePage ? ` p.${continueChapter.resumePage}` : ""}`}
</button> </button>
{/if} {/if}
<div class="actions"> <div class="actions">
@@ -739,18 +792,32 @@
{#if availableScanlators.length > 1} {#if availableScanlators.length > 1}
<div class="scan-filter-wrap"> <div class="scan-filter-wrap">
<button class="icon-btn" class:active={scanlatorFilter.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator"> <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 ? "fill" : "light"} /> <Funnel size={14} weight={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0 ? "fill" : "light"} />
</button> </button>
{#if scanFilterOpen} {#if scanFilterOpen}
<div class="scan-filter-panel" role="menu"> <div class="scan-filter-panel" role="menu">
<div class="scan-filter-header"> <div class="scan-filter-header">
<span class="scan-filter-heading">Scanlators</span> <div class="scan-filter-tabs">
{#if scanlatorFilter.length > 0} <button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === "prefer"} onclick={() => scanTab = "prefer"}>Prefer</button>
<button class="scan-filter-clear" onclick={() => { setPref("scanlatorFilter", []); chapterPage = 1; }}>Clear</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} {/if}
</div> </div>
<div class="scan-filter-divider"></div> <div class="scan-filter-divider"></div>
{#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} {#each availableScanlators as s}
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorFilter.includes(s)} role="menuitem" <button class="scan-filter-item" class:scan-filter-item-active={scanlatorFilter.includes(s)} role="menuitem"
onclick={() => { onclick={() => {
@@ -766,6 +833,23 @@
{s} {s}
</button> </button>
{/each} {/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> </div>
{/if} {/if}
</div> </div>
@@ -888,7 +972,7 @@
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
{@const isGridSelected = selectedIds.has(ch.id)} {@const isGridSelected = selectedIds.has(ch.id)}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected} <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 }; }} oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}> title={ch.name}>
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span> <span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
@@ -901,9 +985,10 @@
{#each pageChapters as ch} {#each pageChapters as ch}
{@const idxInSorted = sortedChapters.indexOf(ch)} {@const idxInSorted = sortedChapters.indexOf(ch)}
{@const isSelected = selectedIds.has(ch.id)} {@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} <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)} 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))} 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 }; }}> 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"> <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} {#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> <span class="link-title">Link as same series</span>
<button class="link-close" onclick={closeLinkPicker}><X size={14} weight="light" /></button> <button class="link-close" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
</div> </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"> <div class="link-search-wrap">
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusOnMount /> <input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusOnMount />
</div> </div>
@@ -1240,6 +1325,18 @@
.scan-filter-item:hover { background: var(--bg-overlay); color: var(--text-primary); } .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 { color: var(--accent-fg); background: var(--accent-muted); }
.scan-filter-item-active:hover { background: var(--accent-dim); } .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 { 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-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> </style>
File diff suppressed because it is too large Load Diff
+70 -13
View File
@@ -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_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 { GET_ALL_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; 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"; import type { Manga, Chapter, Category } from "../../lib/types";
let manga: Manga | null = $state(null); let manga: Manga | null = $state(null);
@@ -87,11 +87,38 @@
const continueChapter = $derived.by(() => { const continueChapter = $derived.by(() => {
if (!chapters.length) return null; if (!chapters.length) return null;
const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0); const asc = [...chapters]; // already sorted by sourceOrder from load()
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` }; const anyRead = asc.some(c => c.isRead);
const firstUnread = chapters.find((c) => !c.isRead);
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` }; const bookmark = displayManga
return { ch: chapters[0], label: "Read again" }; ? 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); } }); $effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
@@ -187,9 +214,12 @@
} }
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) { async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA); const mangaStatus = (manga ?? displayManga)?.status;
// Sync local mangaCategories state after the mutation await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
if (chaps.length) { // 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 allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed"); const completed = allCategories.find(c => c.name === "Completed");
if (completed) { if (completed) {
@@ -209,6 +239,11 @@
addTo: inCat ? [] : [cat.id], addTo: inCat ? [] : [cat.id],
removeFrom: inCat ? [cat.id] : [], removeFrom: inCat ? [cat.id] : [],
}).catch(console.error); }).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 = inCat
? mangaCategories.filter(c => c.id !== cat.id) ? mangaCategories.filter(c => c.id !== cat.id)
: [...mangaCategories, cat]; : [...mangaCategories, cat];
@@ -222,6 +257,11 @@
const cat = res.createCategory.category; const cat = res.createCategory.category;
allCategories = [...allCategories, cat]; allCategories = [...allCategories, cat];
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.previewManga.id, addTo: [cat.id], removeFrom: [] }); 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]; mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
newFolderName = ""; creatingFolder = false; newFolderName = ""; creatingFolder = false;
@@ -357,8 +397,25 @@
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div> <div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
{/if} {/if}
{#if continueChapter} {#if continueChapter}
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters, displayManga); close(); }}> <button class="read-btn" onclick={() => {
<Play size={12} weight="fill" />{continueChapter.label} 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> </button>
{/if} {/if}
{:else if !loadingDetail} {:else if !loadingDetail}
@@ -387,7 +444,7 @@
{#if !loadingDetail && displayManga?.genre?.length} {#if !loadingDetail && displayManga?.genre?.length}
<div class="genres"> <div class="genres">
{#each displayManga.genre as g} {#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} {/each}
</div> </div>
{/if} {/if}
@@ -423,7 +480,7 @@
<button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button> <button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
</div> </div>
<p class="link-hint"> <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. Click a linked entry again to unlink.
</p> </p>
<div class="link-search-wrap"> <div class="link-search-wrap">
+119
View File
@@ -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>
+5 -6
View File
@@ -29,7 +29,7 @@ export function fetchAuthenticated(
return fetch(url, { return fetch(url, {
...init, ...init,
signal, signal,
credentials: "include", credentials: "omit",
headers: { headers: {
...(init.headers as Record<string, string> ?? {}), ...(init.headers as Record<string, string> ?? {}),
...(user && pass ? basicHeader(user, pass) : {}), ...(user && pass ? basicHeader(user, pass) : {}),
@@ -37,12 +37,13 @@ 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> { export async function loginBasic(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, { const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", method: "POST",
credentials: "omit",
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) }, headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
body: JSON.stringify({ query: "{ __typename }" }), body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
@@ -71,15 +72,13 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
const res = await fetch(`${base}/api/graphql`, { const res = await fetch(`${base}/api/graphql`, {
method: "POST", method: "POST",
credentials: "include", credentials: "omit",
headers, headers,
body: JSON.stringify({ query: "{ __typename }" }), body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(2000), signal: AbortSignal.timeout(2000),
}); });
if (res.ok) { if (res.ok) return "ok";
return "ok";
}
if (res.status === 401) { if (res.status === 401) {
const wwwAuth = res.headers.get("WWW-Authenticate") ?? ""; const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
+1 -1
View File
@@ -155,7 +155,7 @@ export const CACHE_KEYS = {
LIBRARY: "library", LIBRARY: "library",
ALL_MANGA: "all_manga_unfiltered", ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories", 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", SOURCES: "sources",
POPULAR: "popular", POPULAR: "popular",
GENRE: (genre: string) => `genre:${genre}`, GENRE: (genre: string) => `genre:${genre}`,
+18 -5
View File
@@ -4,7 +4,7 @@ import { store } from "../store/state.svelte";
const cache = new Map<string, string>(); const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>(); const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 14; const MAX_CONCURRENT = 6;
let active = 0; let active = 0;
interface QueueEntry { interface QueueEntry {
@@ -30,9 +30,18 @@ async function doFetch(url: string): Promise<string> {
return blobUrl; return blobUrl;
} }
function insertSorted(entry: QueueEntry) {
let lo = 0, hi = queue.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (queue[mid].priority > entry.priority) lo = mid + 1;
else hi = mid;
}
queue.splice(lo, 0, entry);
}
function drain() { function drain() {
while (active < MAX_CONCURRENT && queue.length > 0) { while (active < MAX_CONCURRENT && queue.length > 0) {
queue.sort((a, b) => b.priority - a.priority);
const entry = queue.shift()!; const entry = queue.shift()!;
active++; active++;
doFetch(entry.url) doFetch(entry.url)
@@ -47,7 +56,7 @@ function drain() {
function enqueue(url: string, priority: number): Promise<string> { function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => { const promise = new Promise<string>((resolve, reject) => {
queue.push({ url, priority, resolve, reject }); insertSorted({ url, priority, resolve, reject });
}); });
inflight.set(url, promise); inflight.set(url, promise);
drain(); drain();
@@ -62,8 +71,12 @@ export function getBlobUrl(url: string, priority = 0): Promise<string> {
const existing = inflight.get(url); const existing = inflight.get(url);
if (existing) { if (existing) {
const entry = queue.find(e => e.url === url); const idx = queue.findIndex(e => e.url === url);
if (entry && priority > entry.priority) entry.priority = priority; if (idx !== -1 && priority > queue[idx].priority) {
const [entry] = queue.splice(idx, 1);
entry.priority = priority;
insertSorted(entry);
}
return existing; return existing;
} }
+12 -12
View File
@@ -1,12 +1,12 @@
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
export interface Keybinds { export interface Keybinds {
pageRight: string; turnPageRight: string;
pageLeft: string; turnPageLeft: string;
firstPage: string; firstPage: string;
lastPage: string; lastPage: string;
chapterRight: string; turnChapterRight: string;
chapterLeft: string; turnChapterLeft: string;
exitReader: string; exitReader: string;
toggleReadingDirection: string; toggleReadingDirection: string;
togglePageStyle: string; togglePageStyle: string;
@@ -17,12 +17,12 @@ export interface Keybinds {
} }
export const DEFAULT_KEYBINDS: Keybinds = { export const DEFAULT_KEYBINDS: Keybinds = {
pageRight: "ArrowRight", turnPageRight: "ArrowRight",
pageLeft: "ArrowLeft", turnPageLeft: "ArrowLeft",
firstPage: "ctrl+ArrowLeft", firstPage: "ctrl+ArrowLeft",
lastPage: "ctrl+ArrowRight", lastPage: "ctrl+ArrowRight",
chapterRight: "]", turnChapterRight: "]",
chapterLeft: "[", turnChapterLeft: "[",
exitReader: "Backspace", exitReader: "Backspace",
toggleReadingDirection: "d", toggleReadingDirection: "d",
togglePageStyle: "q", togglePageStyle: "q",
@@ -33,12 +33,12 @@ export const DEFAULT_KEYBINDS: Keybinds = {
}; };
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = { export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
pageRight: "Turn page right", turnPageRight: "Turn page right (→)",
pageLeft: "Turn page left", turnPageLeft: "Turn page left (←)",
firstPage: "Jump to first page", firstPage: "Jump to first page",
lastPage: "Jump to last page", lastPage: "Jump to last page",
chapterRight: "Next chapter", turnChapterRight: "Turn chapter right (→)",
chapterLeft: "Previous chapter", turnChapterLeft: "Turn chapter left (←)",
exitReader: "Exit reader", exitReader: "Exit reader",
toggleReadingDirection: "Toggle reading direction", toggleReadingDirection: "Toggle reading direction",
togglePageStyle: "Toggle page style", togglePageStyle: "Toggle page style",
+94
View File
@@ -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 = ` export const MARK_CHAPTER_READ = `
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) { mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) { 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
}
}
}
}
`;
+54 -9
View File
@@ -154,6 +154,14 @@ export interface ReadingStats {
lastStreakDate: string; lastStreakDate: string;
} }
export interface LibraryUpdateEntry {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
newChapters: number;
checkedAt: number;
}
const AVG_MIN_PER_CHAPTER = 5; const AVG_MIN_PER_CHAPTER = 5;
export const DEFAULT_READING_STATS: ReadingStats = { export const DEFAULT_READING_STATS: ReadingStats = {
@@ -263,6 +271,7 @@ export interface Settings {
customThemes: CustomTheme[]; customThemes: CustomTheme[];
hiddenCategoryIds: number[]; hiddenCategoryIds: number[];
defaultLibraryCategoryId: number | null; defaultLibraryCategoryId: number | null;
savedIsDefaultCategory: boolean;
nsfwFilteredTags: string[]; nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[]; nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[]; nsfwBlockedSourceIds: string[];
@@ -334,6 +343,7 @@ export const DEFAULT_SETTINGS: Settings = {
customThemes: [], customThemes: [],
hiddenCategoryIds: [], hiddenCategoryIds: [],
defaultLibraryCategoryId: null, defaultLibraryCategoryId: null,
savedIsDefaultCategory: false,
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"], nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: [], nsfwAllowedSourceIds: [],
nsfwBlockedSourceIds: [], nsfwBlockedSourceIds: [],
@@ -433,9 +443,13 @@ class Store {
markers: MarkerEntry[] = $state(saved?.markers ?? []); markers: MarkerEntry[] = $state(saved?.markers ?? []);
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []); readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS }); readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
discoverCache: Map<string, any> = $state(new Map()); searchCache: Map<string, any> = $state(new Map());
discoverLibraryIds: Set<number> = $state(new Set()); searchLibraryIds: Set<number> = $state(new Set());
discoverSrcOffset: number = $state(0); 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() { constructor() {
$effect.root(() => { $effect.root(() => {
@@ -447,6 +461,9 @@ class Store {
markers: this.markers, markers: this.markers,
readLog: this.readLog, readLog: this.readLog,
readingStats: this.readingStats, readingStats: this.readingStats,
libraryUpdates: this.libraryUpdates,
lastLibraryRefresh: this.lastLibraryRefresh,
acknowledgedUpdateIds: [...this.acknowledgedUpdates],
storeVersion: STORE_VERSION, storeVersion: STORE_VERSION,
}); });
}); });
@@ -641,8 +658,11 @@ class Store {
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>, gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
UPDATE_MANGA_CATEGORIES: string, UPDATE_MANGA_CATEGORIES: string,
UPDATE_MANGA?: string, UPDATE_MANGA?: string,
mangaStatus?: string,
): Promise<void> { ): Promise<void> {
if (!chaps.length) return; 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 allRead = chaps.every(c => c.isRead);
const completed = categories.find(c => c.name === "Completed"); const completed = categories.find(c => c.name === "Completed");
if (!completed) return; if (!completed) return;
@@ -662,10 +682,30 @@ class Store {
this.settings = { ...this.settings, hiddenCategoryIds: next }; this.settings = { ...this.settings, hiddenCategoryIds: next };
} }
clearDiscoverCache() { clearSearchCache() {
this.discoverCache = new Map(); this.searchCache = new Map();
this.discoverLibraryIds = new Set(); this.searchLibraryIds = new Set();
this.discoverSrcOffset++; 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 setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); } export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
export function resetKeybinds() { store.resetKeybinds(); } 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 addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); } export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
export function clearBookmarks() { store.clearBookmarks(); } export function clearBookmarks() { store.clearBookmarks(); }
@@ -720,6 +764,7 @@ export async function checkAndMarkCompleted(
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>, gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
UPDATE_MANGA_CATEGORIES: string, UPDATE_MANGA_CATEGORIES: string,
UPDATE_MANGA?: string, UPDATE_MANGA?: string,
mangaStatus?: string,
): Promise<void> { ): 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);
} }