Compare commits

..

38 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
Youwes09 023b23288b Chore: Update for 0.7.1 2026-04-05 20:02:27 -05:00
Youwes09 67a9f0b944 Fix: SplashScreen Scaling on Windows (WIP) 2026-04-06 00:39:16 -05:00
Youwes09 56392e2427 Fix: Caching Logic & Settings Warning for Auth 2026-04-05 11:54:46 -05:00
Youwes09 843e205072 Feat: Scanlator-based Filtering & Directory Changes 2026-04-05 11:36:43 -05:00
Youwes09 ee708d85d0 Fix: Persistent Security State 2026-04-05 00:59:27 -05:00
Youwes09 8005c82654 Fix: Update flake.nix Hash 2026-04-04 23:31:53 -05:00
Youwes09 d989b2d67e Fix: Tauri-Plugin-HTTP for Windows Auth Support (Major WIP) 2026-04-05 04:14:33 -05:00
Youwes09 6446a19b2d Fix: Auth Thumbnails on Windows (WIP) 2026-04-04 19:28:00 -05:00
Youwes09 5cd96abc0c Feat: Switch DRPC Plugins 2026-04-03 22:07:42 -05:00
63 changed files with 4877 additions and 2724 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

+16 -4
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }: perSystem = { system, lib, ... }:
let let
version = "0.7.0"; version = "0.8.0";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
@@ -71,7 +71,7 @@
inherit version; inherit version;
src = frontendSrc; src = frontendSrc;
fetcherVersion = 1; fetcherVersion = 1;
hash = "sha256-ezlckHhfSIe/Hs30tbiN0a/EuvGxhO5L020aup23Ozg="; hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
}; };
buildPhase = "pnpm build"; buildPhase = "pnpm build";
@@ -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"
@@ -264,6 +264,15 @@ EOF
''; '';
}; };
tunnelScript = pkgs.writeShellApplication {
name = "moku-tunnel";
runtimeInputs = with pkgs; [ cloudflared ];
text = ''
PORT="''${1:-4567}"
cloudflared tunnel --url "http://localhost:$PORT"
'';
};
in in
{ {
apps = { apps = {
@@ -272,6 +281,7 @@ EOF
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; }; bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; }; flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; }; pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
}; };
packages = { packages = {
@@ -288,6 +298,7 @@ EOF
nodejs_22 nodejs_22
pnpm pnpm
suwayomi-server suwayomi-server
cloudflared
xdg-utils xdg-utils
]; ];
shellHook = '' shellHook = ''
@@ -301,6 +312,7 @@ EOF
echo " nix run .#bump -- <ver> bump versions only" echo " nix run .#bump -- <ver> bump versions only"
echo " nix run .#flatpak -- <ver> full flatpak build" echo " nix run .#flatpak -- <ver> full flatpak build"
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)" echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
''; '';
}; };
@@ -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: 43b7274bdab884aacbc3dad6f0f7c043d8e3d82b7bf7398e1df9f516ed553152 sha256: f21034da4b8da42d8084978b60e429162aabb28808fa019ffb786e877c4ae95b
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+2
View File
@@ -11,11 +11,13 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-http": "^2.5.8",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0", "phosphor-svelte": "^3.1.0",
"svelte-spa-router": "^4.0.1", "svelte-spa-router": "^4.0.1",
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
"tauri-plugin-drpc": "^1.0.3" "tauri-plugin-drpc": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
File diff suppressed because it is too large Load Diff
-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>
+21
View File
@@ -11,6 +11,9 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.10.1 version: 2.10.1
'@tauri-apps/plugin-http':
specifier: ^2.5.8
version: 2.5.8
'@tauri-apps/plugin-os': '@tauri-apps/plugin-os':
specifier: ^2.3.2 specifier: ^2.3.2
version: 2.3.2 version: 2.3.2
@@ -26,6 +29,9 @@ importers:
svelte-spa-router: svelte-spa-router:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.2 version: 4.0.2
tauri-plugin-discord-rpc-api:
specifier: github:Youwes09/tauri-plugin-discord-rpc
version: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a
tauri-plugin-drpc: tauri-plugin-drpc:
specifier: ^1.0.3 specifier: ^1.0.3
version: 1.0.3(typescript@5.9.3) version: 1.0.3(typescript@5.9.3)
@@ -442,6 +448,9 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-http@2.5.8':
resolution: {integrity: sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw==}
'@tauri-apps/plugin-os@2.3.2': '@tauri-apps/plugin-os@2.3.2':
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
@@ -747,6 +756,10 @@ packages:
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==} resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
engines: {node: '>=18'} engines: {node: '>=18'}
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a:
resolution: {tarball: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a}
version: 0.1.0
tauri-plugin-drpc@1.0.3: tauri-plugin-drpc@1.0.3:
resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==} resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==}
peerDependencies: peerDependencies:
@@ -1046,6 +1059,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-http@2.5.8':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-os@2.3.2': '@tauri-apps/plugin-os@2.3.2':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
@@ -1372,6 +1389,10 @@ snapshots:
magic-string: 0.30.21 magic-string: 0.30.21
zimmerframe: 1.1.4 zimmerframe: 1.1.4
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a:
dependencies:
'@tauri-apps/api': 2.10.1
tauri-plugin-drpc@1.0.3(typescript@5.9.3): tauri-plugin-drpc@1.0.3(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
+325 -155
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.7.0" version = "0.8.0"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -20,13 +20,17 @@ 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-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
walkdir = "2" walkdir = "2"
sysinfo = "0.32" sysinfo = "0.32"
dirs = "5" dirs = "5"
tauri-plugin-os = "2.3.2" urlencoding = "2"
tauri-plugin-drpc = "0.1.6" tokio = { version = "1", features = ["rt-multi-thread"] }
reqwest = { version = "0.12", features = ["blocking"] }
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
+6 -6
View File
@@ -33,11 +33,11 @@
"process:allow-restart", "process:allow-restart",
"http:default", "http:default",
"http:allow-fetch", "http:allow-fetch",
"drpc:default", "discord-rpc:default",
"drpc:allow-is-running", "discord-rpc:allow-connect",
"drpc:allow-spawn-thread", "discord-rpc:allow-disconnect",
"drpc:allow-destroy-thread", "discord-rpc:allow-set-activity",
"drpc:allow-set-activity", "discord-rpc:allow-clear-activity",
"drpc:allow-clear-activity" "discord-rpc:allow-is-running"
] ]
} }
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "http-scope",
"description": "HTTP fetch scope",
"windows": ["main"],
"permissions": [
{
"identifier": "http:default",
"allow": [
{ "url": "http://*:*/*" },
{ "url": "https://*:*/*" },
{ "url": "http://*/*" },
{ "url": "https://*/*" }
]
}
]
}
+95 -186
View File
@@ -26,9 +26,6 @@ pub enum SpawnError {
SpawnFailed(String), SpawnFailed(String),
} }
// ── Update types ──────────────────────────────────────────────────────────────
/// A single GitHub release returned to the frontend.
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct ReleaseInfo { pub struct ReleaseInfo {
pub tag_name: String, pub tag_name: String,
@@ -38,7 +35,6 @@ pub struct ReleaseInfo {
pub html_url: String, pub html_url: String,
} }
/// Progress event emitted during download — matches what the frontend listens for.
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))] #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
struct UpdateProgress { struct UpdateProgress {
@@ -46,8 +42,6 @@ struct UpdateProgress {
total: Option<u64>, total: Option<u64>,
} }
/// Strip the \\?\ extended-length path prefix that Windows adds to long paths.
/// Java and many other tools do not accept this prefix and will fail silently.
fn strip_unc(path: PathBuf) -> PathBuf { fn strip_unc(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy(); let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") { if let Some(stripped) = s.strip_prefix(r"\\?\") {
@@ -61,10 +55,6 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() { if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path); return PathBuf::from(downloads_path);
} }
// Mirror Suwayomi-Server's own default: <data_dir>/Tachidesk/downloads
// Windows: %LOCALAPPDATA%\Tachidesk\downloads
// macOS: ~/Library/Application Support/Tachidesk/downloads
// Linux: $XDG_DATA_HOME/Tachidesk/downloads (~/.local/share/Tachidesk/downloads)
let base = std::env::var("XDG_DATA_HOME") let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/"))); .unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
@@ -108,34 +98,23 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
}) })
} }
/// Returns the resolved default downloads path for the current platform.
/// This mirrors resolve_downloads_path("") so the frontend can display it.
#[tauri::command] #[tauri::command]
fn get_default_downloads_path() -> String { fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned() resolve_downloads_path("").to_string_lossy().into_owned()
} }
/// Returns true if the given path exists and is a directory.
#[tauri::command] #[tauri::command]
fn check_path_exists(path: String) -> bool { fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir() std::path::Path::new(path.trim()).is_dir()
} }
/// Creates a directory and all missing parent directories.
#[tauri::command] #[tauri::command]
fn create_directory(path: String) -> Result<(), String> { fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string()) std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
} }
/// Moves all content from `src` into `dst`, then removes `src`.
/// Emits `migrate_progress` events: `{ done, total, current }`.
/// Only deletes the source tree after every file is confirmed copied.
#[tauri::command] #[tauri::command]
async fn migrate_downloads( async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
app: tauri::AppHandle,
src: String,
dst: String,
) -> Result<(), String> {
use tauri::Emitter; use tauri::Emitter;
use std::fs; use std::fs;
@@ -143,19 +122,16 @@ async fn migrate_downloads(
let dst_path = std::path::PathBuf::from(dst.trim()); let dst_path = std::path::PathBuf::from(dst.trim());
if !src_path.is_dir() { if !src_path.is_dir() {
return Ok(()); // nothing to migrate return Ok(());
} }
// Count files first so the frontend can show accurate progress
let total: u64 = WalkDir::new(&src_path) let total: u64 = WalkDir::new(&src_path)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file()) .filter(|e| e.file_type().is_file())
.count() as u64; .count() as u64;
let _ = app.emit("migrate_progress", serde_json::json!({ let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
"done": 0u64, "total": total, "current": ""
}));
let mut done: u64 = 0; let mut done: u64 = 0;
@@ -172,23 +148,15 @@ async fn migrate_downloads(
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?; fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
done += 1; done += 1;
let _ = app.emit("migrate_progress", serde_json::json!({ let _ = app.emit("migrate_progress", serde_json::json!({
"done": done, "done": done, "total": total, "current": rel.to_string_lossy()
"total": total,
"current": rel.to_string_lossy()
})); }));
} }
} }
// Only remove source after all files are confirmed copied
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?; fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
/// Returns the OS/monitor DPI scale factor for the window's current monitor.
/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K,
/// 1.251.5 on Windows displays with OS-level scaling applied.
/// The frontend multiplies this by the user's uiZoom preference to get the
/// final effective zoom applied to document.documentElement.
#[tauri::command] #[tauri::command]
fn get_platform_ui_scale(window: tauri::Window) -> f64 { fn get_platform_ui_scale(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0) window.scale_factor().unwrap_or(1.0)
@@ -210,8 +178,6 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
.creation_flags(CREATE_NO_WINDOW) .creation_flags(CREATE_NO_WINDOW)
.status(); .status();
// Poll until no java.exe remains (up to ~3 s) so the installer can
// overwrite the JRE DLLs without hitting a sharing-violation error.
for _ in 0..30 { for _ in 0..30 {
let still_running = std::process::Command::new("tasklist") let still_running = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq java.exe", "/NH"]) .args(["/FI", "IMAGENAME eq java.exe", "/NH"])
@@ -220,20 +186,15 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe")) .map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
.unwrap_or(false); .unwrap_or(false);
if !still_running { if !still_running { break; }
break;
}
std::thread::sleep(std::time::Duration::from_millis(100)); std::thread::sleep(std::time::Duration::from_millis(100));
} }
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new("pkill") let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
.args(["-f", "tachidesk"])
.status();
} }
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1" const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = false
@@ -311,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")))]
{ {
@@ -328,23 +289,14 @@ struct ServerInvocation {
working_dir: Option<PathBuf>, working_dir: Option<PathBuf>,
} }
/// Locate the `java` / `java.exe` binary inside a bundled JRE directory.
///
/// Expected layout (Windows and Linux):
/// <bundle_dir>/jre/bin/java[.exe]
///
/// On Windows `strip_unc` is applied so Java doesn't choke on `\\?\` prefixes.
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> { fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe")); let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java"); let java = bundle_dir.join("jre").join("bin").join("java");
do_log(log, &format!("[find_java] checking path: {:?}", java)); do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
do_log(log, &format!("[find_java] exists: {}", java.exists()));
if java.exists() { Some(java) } else { None } if java.exists() { Some(java) } else { None }
} }
@@ -360,12 +312,8 @@ fn resolve_server_binary(
app: &tauri::AppHandle, app: &tauri::AppHandle,
log: &mut Option<std::fs::File>, log: &mut Option<std::fs::File>,
) -> Result<ServerInvocation, SpawnError> { ) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary arg = {:?}", binary)); do_log(log, &format!("[resolve] binary = {:?}", binary));
// ── 1. User-specified binary path ─────────────────────────────────────────
// Primary: honour the path as-is (doc-2 behaviour — trust the user).
// Fallback: if the path doesn't exist after stripping UNC, log a warning
// and continue so the bundled detection still has a chance.
if !binary.trim().is_empty() { if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim())); let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists())); do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
@@ -376,62 +324,60 @@ fn resolve_server_binary(
working_dir: path.parent().map(|p| p.to_path_buf()), working_dir: path.parent().map(|p| p.to_path_buf()),
}); });
} }
// Fallback: path was set but file is missing — warn and keep trying. do_log(log, "[resolve] user path not found, falling through");
do_log(log, "[resolve] WARNING: user-supplied path not found, falling through to bundled detection"); }
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()),
});
}
}
}
} }
// Resolve and UNC-strip resource_dir once; used by all non-macOS branches.
#[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();
let stripped = strip_unc(raw); let stripped = strip_unc(raw);
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped)); do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
stripped stripped
}; };
// ── 2. Bundled JRE + JAR (Windows / Linux — specific layout) ─────────────
// Primary path from doc-2: binaries/suwayomi-bundle/bin/Suwayomi-Server.jar
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle"); let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar"); let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir)); do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists())); do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
do_log(log, &format!("[resolve] jar = {:?}", jar));
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
match find_java_in_bundle(&bundle_dir, log) { match find_java_in_bundle(&bundle_dir, log) {
Some(java) => { Some(java) if jar.exists() => {
do_log(log, &format!("[resolve] java found: {:?}", java)); do_log(log, "[resolve] using bundled JRE");
if jar.exists() {
do_log(log, "[resolve] both java and jar found — using bundled JRE");
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(), bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()], args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir: Some(bundle_dir), working_dir: Some(bundle_dir),
}); });
} }
do_log(log, "[resolve] java found but jar MISSING — falling through"); _ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
}
None => {
do_log(log, "[resolve] java NOT found in bundle — falling through");
}
} }
} }
// ── 2b. Bundled launcher scripts / native sidecars (Windows / Linux) ──────
// Fallback for older bundle layouts that ship a wrapper script instead of a
// bare JRE + JAR. Also scans for any *.jar in resource_dir as a last resort.
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
// Named launcher scripts. for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
let script_candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
for name in &script_candidates {
let p = resource_dir.join(name); let p = resource_dir.join(name);
do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists())); do_log(log, &format!("[resolve] sidecar: {:?} exists={}", p, p.exists()));
if p.exists() { if p.exists() {
do_log(log, &format!("[resolve] using sidecar: {:?}", p));
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(), bin: p.to_string_lossy().into_owned(),
args: vec![], args: vec![],
@@ -440,50 +386,31 @@ fn resolve_server_binary(
} }
} }
// Generic JRE at resource_dir root + any *.jar alongside it.
do_log(log, "[resolve] no named sidecar found, trying generic JRE + any jar in resource_dir");
if let Some(java) = find_java_in_bundle(&resource_dir, log) { if let Some(java) = find_java_in_bundle(&resource_dir, log) {
let jar = std::fs::read_dir(&resource_dir) let jar = std::fs::read_dir(&resource_dir)
.ok() .ok()
.and_then(|mut rd| { .and_then(|mut rd| {
rd.find(|e| { rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
e.as_ref()
.map(|e| e.file_name().to_string_lossy().ends_with(".jar"))
.unwrap_or(false)
})
.and_then(|e| e.ok()) .and_then(|e| e.ok())
.map(|e| e.path()) .map(|e| e.path())
}); });
do_log(log, &format!("[resolve] generic jar candidate: {:?}", jar));
if let Some(jar_path) = jar { if let Some(jar_path) = jar {
do_log(log, &format!("[resolve] using generic JRE java={:?} jar={:?}", java, jar_path)); do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(), bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()], args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
working_dir: Some(resource_dir), working_dir: Some(resource_dir),
}); });
} }
do_log(log, "[resolve] generic JRE found but no .jar — falling through");
} }
} }
// ── 3. macOS app bundle — MacOS/ then Resources/ ──────────────────────────
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
let resource_dir = app.path().resource_dir().unwrap_or_default(); let resource_dir = app.path().resource_dir().unwrap_or_default();
let macos_dir = resource_dir let macos_dir = resource_dir.parent().map(|p| p.join("MacOS")).unwrap_or_default();
.parent()
.map(|p| p.join("MacOS"))
.unwrap_or_default();
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
// Tauri strips the target triple when installing externalBin sidecars into
// Contents/MacOS/, so the binary is "suwayomi-server" at runtime.
// Triple-suffixed names are kept as a belt-and-suspenders fallback for
// dev / flat layouts.
let candidates = [ let candidates = [
"suwayomi-server", "suwayomi-server",
"suwayomi-server-aarch64-apple-darwin", "suwayomi-server-aarch64-apple-darwin",
@@ -498,7 +425,6 @@ fn resolve_server_binary(
let p = search_dir.join(name); let p = search_dir.join(name);
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists())); do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
if p.exists() { if p.exists() {
do_log(log, &format!("[resolve] using macOS sidecar: {:?}", p));
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(), bin: p.to_string_lossy().into_owned(),
args: vec![], args: vec![],
@@ -509,37 +435,17 @@ fn resolve_server_binary(
} }
} }
// ── 4. PATH fallback ──────────────────────────────────────────────────────
// Use `where` on Windows, `which` everywhere else.
do_log(log, "[resolve] trying PATH fallback");
for name in &["suwayomi-server", "tachidesk-server"] { for name in &["suwayomi-server", "tachidesk-server"] {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let found = std::process::Command::new("where") let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
.arg(name)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let found = std::process::Command::new("which") let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
.arg(name)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
do_log(log, &format!("[resolve] PATH check {:?}: found={}", name, found));
if found { if found {
do_log(log, &format!("[resolve] using PATH binary: {}", name)); return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
return Ok(ServerInvocation {
bin: name.to_string(),
args: vec![],
working_dir: None,
});
} }
} }
do_log(log, "[resolve] FAILED — no binary found anywhere");
Err(SpawnError::NotConfigured( Err(SpawnError::NotConfigured(
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(), "Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
)) ))
@@ -555,50 +461,30 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
} }
let data_dir = suwayomi_data_dir(); let data_dir = suwayomi_data_dir();
let log_path = data_dir.join("moku-spawn.log"); let log_path = data_dir.join("moku-spawn.log");
let _ = std::fs::create_dir_all(&data_dir); let _ = std::fs::create_dir_all(&data_dir);
let mut log = std::fs::OpenOptions::new() let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
.create(true)
.append(true)
.open(&log_path)
.ok();
do_log(&mut log, ""); do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
do_log(&mut log, "========================================");
do_log(&mut log, &format!("[spawn_server] called at {:?}", std::time::SystemTime::now()));
do_log(&mut log, &format!("[spawn_server] binary arg = {:?}", binary));
do_log(&mut log, &format!("[spawn_server] data_dir = {:?}", data_dir));
do_log(&mut log, &format!("[spawn_server] log file = {:?}", log_path));
do_log(&mut log, &format!("[spawn_server] APPDATA = {:?}", std::env::var("APPDATA")));
do_log(&mut log, &format!("[spawn_server] LOCALAPPDATA = {:?}", std::env::var("LOCALAPPDATA")));
do_log(&mut log, &format!("[spawn_server] current_dir = {:?}", std::env::current_dir()));
seed_server_conf(&data_dir); seed_server_conf(&data_dir);
do_log(&mut log, "[spawn_server] server.conf seeded");
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) { let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
Ok(i) => i, do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
Err(e) => { e
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e)); })?;
return Err(e);
}
};
let bin_display = invocation.bin.clone(); 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 let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
do_log(&mut log, &format!("[spawn_server] bin = {:?}", bin_display)); do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
do_log(&mut log, &format!("[spawn_server] args = {:?}", invocation.args));
do_log(&mut log, &format!("[spawn_server] working_dir = {:?}", working_dir));
let cmd = app.shell() let cmd = app.shell()
.command(&invocation.bin) .command(&invocation.bin)
@@ -606,17 +492,13 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
.args(&invocation.args) .args(&invocation.args)
.current_dir(&working_dir); .current_dir(&working_dir);
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
match cmd.spawn() { match cmd.spawn() {
Ok((_rx, child)) => { Ok((_rx, child)) => {
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
*app.state::<ServerState>().0.lock().unwrap() = Some(child); *app.state::<ServerState>().0.lock().unwrap() = Some(child);
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e)); do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
Err(SpawnError::SpawnFailed(e.to_string())) Err(SpawnError::SpawnFailed(e.to_string()))
} }
} }
@@ -628,10 +510,6 @@ fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
// ── Update commands ───────────────────────────────────────────────────────────
/// Fetch the list of all GitHub releases so the frontend can show a version picker.
/// Uses tauri-plugin-http so it goes through Tauri's permission system.
#[tauri::command] #[tauri::command]
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> { async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
use tauri_plugin_http::reqwest; use tauri_plugin_http::reqwest;
@@ -663,22 +541,15 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
let body = resp.text().await.map_err(|e| e.to_string())?; let body = resp.text().await.map_err(|e| e.to_string())?;
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?; let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
Ok(releases Ok(releases.into_iter().map(|r| ReleaseInfo {
.into_iter()
.map(|r| ReleaseInfo {
tag_name: r.tag_name.clone(), tag_name: r.tag_name.clone(),
name: r.name.unwrap_or_else(|| r.tag_name.clone()), name: r.name.unwrap_or_else(|| r.tag_name.clone()),
body: r.body.unwrap_or_default(), body: r.body.unwrap_or_default(),
published_at: r.published_at.unwrap_or_default(), published_at: r.published_at.unwrap_or_default(),
html_url: r.html_url, html_url: r.html_url,
}) }).collect())
.collect())
} }
/// Download and install the latest update using tauri-plugin-updater.
/// Emits `update-progress` events with `{ downloaded, total }` while downloading.
/// On Windows the installer runs in passive (silent) mode; the frontend prompts restart.
/// On other platforms this command is a no-op — the frontend opens the GitHub page instead.
#[tauri::command] #[tauri::command]
#[allow(unused_variables)] #[allow(unused_variables)]
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> { async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
@@ -693,7 +564,7 @@ async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String
let update = updater.check().await.map_err(|e| e.to_string())?; let update = updater.check().await.map_err(|e| e.to_string())?;
let Some(update) = update else { let Some(update) = update else {
return Err("No update available from the updater endpoint.".into()); return Err("No update available.".into());
}; };
let app_clone = app.clone(); let app_clone = app.clone();
@@ -711,18 +582,54 @@ async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String
} }
} }
/// Restart the app after a successful update install.
#[tauri::command] #[tauri::command]
fn restart_app(app: tauri::AppHandle) { fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env()); tauri::process::restart(&app.env());
} }
// ── App entry point ─────────────────────────────────────────────────────────── #[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_drpc::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())
@@ -741,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.0", "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"
+37 -7
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";
@@ -11,13 +12,13 @@
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte"; import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord"; import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
import Layout from "./components/layout/Layout.svelte"; import Layout from "./components/chrome/Layout.svelte";
import Reader from "./components/reader/Reader.svelte"; import Reader from "./components/reader/Reader.svelte";
import Settings from "./components/settings/Settings.svelte"; import Settings from "./components/settings/Settings.svelte";
import ThemeEditor from "./components/settings/ThemeEditor.svelte"; import ThemeEditor from "./components/settings/ThemeEditor.svelte";
import TitleBar from "./components/layout/TitleBar.svelte"; import TitleBar from "./components/chrome/TitleBar.svelte";
import Toaster from "./components/layout/Toaster.svelte"; import Toaster from "./components/chrome/Toaster.svelte";
import SplashScreen from "./components/layout/SplashScreen.svelte"; import SplashScreen from "./components/chrome/SplashScreen.svelte";
import MangaPreview from "./components/shared/MangaPreview.svelte"; import MangaPreview from "./components/shared/MangaPreview.svelte";
let themeStyleEl: HTMLStyleElement | null = null; let themeStyleEl: HTMLStyleElement | null = null;
@@ -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() {
@@ -221,6 +243,15 @@
if (result === "auth_required") { if (result === "auth_required") {
serverProbeOk = true; serverProbeOk = true;
const savedUser = store.settings.serverAuthUser?.trim() ?? "";
const savedPass = store.settings.serverAuthPass?.trim() ?? "";
if (savedUser && savedPass) {
try {
await loginBasic(savedUser, savedPass);
loginRequired = false;
return;
} catch {}
}
loginRequired = true; loginRequired = true;
return; return;
} }
@@ -443,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; } }
@@ -3,11 +3,9 @@
import Sidebar from "./Sidebar.svelte"; import Sidebar from "./Sidebar.svelte";
import Home from "../pages/Home.svelte"; import Home from "../pages/Home.svelte";
import Library from "../pages/Library.svelte"; import Library from "../pages/Library.svelte";
import SeriesDetail from "../pages/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"}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte"; import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
import { thumbUrl } from "../../lib/client"; import Thumbnail from "../shared/Thumbnail.svelte";
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte"; import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte"; import type { HistoryEntry } from "../../store/state.svelte";
@@ -192,7 +192,7 @@
{#each items as session (session.latestChapterId)} {#each items as session (session.latestChapterId)}
<button class="session-row" onclick={() => resume(session)}> <button class="session-row" onclick={() => resume(session)}>
<div class="thumb-wrap"> <div class="thumb-wrap">
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" /> <Thumbnail src={session.thumbnailUrl} alt={session.mangaTitle} class="thumb" />
{#if session.chapterCount > 1} {#if session.chapterCount > 1}
<span class="session-count">{session.chapterCount}</span> <span class="session-count">{session.chapterCount}</span>
{/if} {/if}
@@ -290,7 +290,7 @@
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); } .session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
.thumb-wrap { position: relative; flex-shrink: 0; } .thumb-wrap { position: relative; flex-shrink: 0; }
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); } :global(.thumb) { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.session-count { .session-count {
position: absolute; bottom: -4px; right: -6px; position: absolute; bottom: -4px; right: -6px;
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
@@ -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() {
@@ -17,8 +17,11 @@
onDismiss?: () => void; onDismiss?: () => void;
} }
let { mode = "loading", ringFull = false, failed = false, notConfigured = false, let {
showCards = true, showFps = false, onReady, onRetry, onBypass, onDismiss }: Props = $props(); mode = "loading", ringFull = false, failed = false,
notConfigured = false, showCards = true, showFps = false,
onReady, onRetry, onBypass, onDismiss,
}: Props = $props();
const lockEnabled = $derived( const lockEnabled = $derived(
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4 store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
@@ -28,6 +31,21 @@
let pinShake = $state(false); let pinShake = $state(false);
let pinUnlocked = $state(false); let pinUnlocked = $state(false);
let pinVisible = $state(false); let pinVisible = $state(false);
let uiScale = $state(1);
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
const logoLoadingSize = 140;
const logoIdleSize = 128;
const logoLockSize = 96;
const ringR = $derived(70);
const ringPad = $derived(12);
const ringSize = $derived((ringR + ringPad) * 2);
const ringC = $derived(ringR + ringPad);
const ringCirc = $derived(2 * Math.PI * ringR);
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
const ringTop = $derived(-((ringSize - logoLoadingSize) / 2));
const ringLeft = $derived(-((ringSize - logoLoadingSize) / 2));
function submitPin() { function submitPin() {
if (pinEntry === store.settings.appLockPin) { if (pinEntry === store.settings.appLockPin) {
@@ -37,7 +55,7 @@
} else { } else {
pinShake = true; pinShake = true;
pinEntry = ""; pinEntry = "";
setTimeout(() => pinShake = false, 500); setTimeout(() => (pinShake = false), 500);
} }
} }
@@ -50,9 +68,6 @@
} }
} }
function handleRetry() { onRetry?.(); }
function handleBypass() { onBypass?.(); }
const EXIT_MS = 320; const EXIT_MS = 320;
const PHASE1_TARGET = 0.85; const PHASE1_TARGET = 0.85;
const PHASE1_MS = 3000; const PHASE1_MS = 3000;
@@ -64,8 +79,6 @@
let exiting = $state(false); let exiting = $state(false);
let exitLock = false; let exitLock = false;
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
function triggerExit(cb?: () => void) { function triggerExit(cb?: () => void) {
if (exitLock) return; if (exitLock) return;
exitLock = true; exitLock = true;
@@ -81,18 +94,14 @@
if (exitLock) return; if (exitLock) return;
if (animStart === null) animStart = ts; if (animStart === null) animStart = ts;
const elapsed = ts - animStart; const elapsed = ts - animStart;
if (animPhase === 1) { if (animPhase === 1) {
const t = Math.min(elapsed / PHASE1_MS, 1); const t = Math.min(elapsed / PHASE1_MS, 1);
const eased = 1 - Math.pow(1 - t, 3); ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
ringProg = 0.025 + eased * (PHASE1_TARGET - 0.025);
if (t >= 1) { animPhase = 2; animStart = ts; } if (t >= 1) { animPhase = 2; animStart = ts; }
} else if (animPhase === 2) { } else {
const t = Math.min(elapsed / PHASE2_MS, 1); const t = Math.min(elapsed / PHASE2_MS, 1);
const eased = 1 - Math.pow(1 - t, 4); ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
ringProg = PHASE1_TARGET + eased * (PHASE2_TARGET - PHASE1_TARGET);
} }
animFrame = requestAnimationFrame(animateRing); animFrame = requestAnimationFrame(animateRing);
} }
@@ -104,26 +113,39 @@
}); });
$effect(() => { $effect(() => {
if (ringFull) { if (!ringFull) return;
cancelAnimationFrame(animFrame); cancelAnimationFrame(animFrame);
ringProg = 1; ringProg = 1;
if (lockEnabled && !pinUnlocked) { if (lockEnabled && !pinUnlocked) {
setTimeout(() => { pinVisible = true; }, 400); setTimeout(() => (pinVisible = true), 400);
} else { } else {
setTimeout(() => triggerExit(onReady), 650); setTimeout(() => triggerExit(onReady), 650);
} }
} });
$effect(() => {
const needsPin =
(mode === "idle" && lockEnabled) ||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
if (!needsPin) return;
window.addEventListener("keydown", onPinKey);
return () => window.removeEventListener("keydown", onPinKey);
});
$effect(() => {
if (pinUnlocked && mode !== "idle") triggerExit(onReady);
}); });
const dotsInterval = setInterval(() => { const dotsInterval = setInterval(() => {
dots = dots.length >= 3 ? "" : dots + "."; dots = dots.length >= 3 ? "" : dots + ".";
}, 420); }, 420);
onMount(() => { onMount(async () => {
const win = getCurrentWindow();
uiScale = await win.scaleFactor();
if (mode === "idle" && onDismiss) { if (mode === "idle" && onDismiss) {
if (lockEnabled) { if (lockEnabled) return () => clearInterval(dotsInterval);
return () => clearInterval(dotsInterval);
}
const handler = () => triggerExit(onDismiss); const handler = () => triggerExit(onDismiss);
const t = setTimeout(() => { const t = setTimeout(() => {
window.addEventListener("keydown", handler, { once: true }); window.addEventListener("keydown", handler, { once: true });
@@ -143,6 +165,7 @@
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; } interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
interface CardTrig { cosA: number; sinA: number; tiltRad: number; } interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
const LAYER_CFG = [ const LAYER_CFG = [
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 }, { wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
@@ -159,7 +182,8 @@
} }
function buildCards(vw: number, vh: number) { function buildCards(vw: number, vh: number) {
const cards: CardDef[] = [], laneW = vw / COLS; const cards: CardDef[] = [];
const laneW = vw / COLS;
for (let layer = 0; layer < 3; layer++) { for (let layer = 0; layer < 3; layer++) {
const cfg = LAYER_CFG[layer]; const cfg = LAYER_CFG[layer];
for (let col = 0; col < COLS; col++) { for (let col = 0; col < COLS; col++) {
@@ -170,10 +194,14 @@
const travel = vh + h + BUF; const travel = vh + h + BUF;
cards.push({ cards.push({
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2), cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
w, h, lines: 1 + Math.floor(hash(seed + 7) * 3), alpha: cfg.alpha, speed, w, h,
lines: 1 + Math.floor(hash(seed + 7) * 3),
alpha: cfg.alpha,
speed,
cycleSec: travel / speed, cycleSec: travel / speed,
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1, phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
travel, yStart: vh + h / 2 + BUF / 2, travel,
yStart: vh + h / 2 + BUF / 2,
angleStart: hash(seed + 3) * 50 - 25, angleStart: hash(seed + 3) * 50 - 25,
tilt: (hash(seed + 4) * 2 - 1) * 18, tilt: (hash(seed + 4) * 2 - 1) * 18,
}); });
@@ -205,11 +233,12 @@
const ctx = oc.getContext("2d")!; const ctx = oc.getContext("2d")!;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
const x0 = STAMP_PAD, y0 = STAMP_PAD; const x0 = STAMP_PAD, y0 = STAMP_PAD;
const coverH = (c.w * 0.72) * 1.05; const coverH = c.w * 0.72 * 1.05;
const lineY0 = y0 + 3 + coverH + 5; const lineY0 = y0 + 3 + coverH + 5;
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill(); ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill(); ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.75)"; ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke(); ctx.strokeStyle = "rgba(255,255,255,0.75)";
ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill(); ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill(); ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
for (let li = 0; li < c.lines; li++) { for (let li = 0; li < c.lines; li++) {
@@ -221,12 +250,17 @@
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement { function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
const oc = document.createElement("canvas"); const oc = document.createElement("canvas");
oc.width = Math.round(vw * dpr); oc.height = Math.round(vh * dpr); oc.width = Math.round(vw * dpr);
oc.height = Math.round(vh * dpr);
const ctx = oc.getContext("2d")!; const ctx = oc.getContext("2d")!;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65); const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(0.4, "rgba(0,0,0,0)"); g.addColorStop(0.7, "rgba(0,0,0,0.25)"); g.addColorStop(1, "rgba(0,0,0,0.65)"); g.addColorStop(0, "rgba(0,0,0,0)");
ctx.fillStyle = g; ctx.fillRect(0, 0, vw, vh); g.addColorStop(0.4, "rgba(0,0,0,0)");
g.addColorStop(0.7, "rgba(0,0,0,0.25)");
g.addColorStop(1, "rgba(0,0,0,0.65)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, vw, vh);
return oc; return oc;
} }
@@ -247,10 +281,11 @@
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta); const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr); ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
const sw = stamps[i].width, sh = stamps[i].height; const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh); ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
} }
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.drawImage(vignette, 0, 0, cw, ch); ctx.drawImage(vignette, 0, 0, cw, ch);
} }
@@ -259,7 +294,8 @@
fpsFrames++; fpsFrames++;
if (now - fpsLast >= 500) { if (now - fpsLast >= 500) {
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000)); fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
fpsFrames = 0; fpsLast = now; fpsFrames = 0;
fpsLast = now;
if (fpsEl) fpsEl.textContent = `${fps} fps`; if (fpsEl) fpsEl.textContent = `${fps} fps`;
} }
} }
@@ -267,10 +303,6 @@
function mountCanvas(el: HTMLCanvasElement) { function mountCanvas(el: HTMLCanvasElement) {
const win = getCurrentWindow(); const win = getCurrentWindow();
const ctx = el.getContext("2d")!; const ctx = el.getContext("2d")!;
interface RenderState {
cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[];
vignette: HTMLCanvasElement; CW: number; CH: number; scale: number;
}
let live: RenderState | null = null; let live: RenderState | null = null;
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0; let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
@@ -289,10 +321,13 @@
} }
const ro = new ResizeObserver(() => syncSize()); const ro = new ResizeObserver(() => syncSize());
ro.observe(el); syncSize(); ro.observe(el);
syncSize();
let raf = 0, t0 = -1, paused = false;
let raf = 0, t0 = -1;
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;
@@ -300,33 +335,35 @@
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(); };
} }
$effect(() => { function onVisibility() {
const needsPin = document.hidden ? pause() : resume();
(mode === "idle" && lockEnabled) ||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
if (!needsPin) return;
window.addEventListener("keydown", onPinKey);
return () => window.removeEventListener("keydown", onPinKey);
});
$effect(() => {
if (pinUnlocked && mode !== "idle") {
triggerExit(onReady);
} }
document.addEventListener("visibilitychange", onVisibility);
const unlistenFocus = win.onFocusChanged(({ payload: focused }) => {
focused ? resume() : pause();
}); });
const ringR = $derived(70); raf = requestAnimationFrame(frame);
const ringPad = $derived(12); return () => {
const ringSize = $derived((ringR + ringPad) * 2); cancelAnimationFrame(raf);
const ringC = $derived(ringR + ringPad); ro.disconnect();
const ringCirc = $derived(2 * Math.PI * ringR); document.removeEventListener("visibilitychange", onVisibility);
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999)); unlistenFocus.then(f => f());
const ringTop = $derived(-((ringSize - 140) / 2)); };
const ringLeft = $derived(-((ringSize - 140) / 2)); }
</script> </script>
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}"> <div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
@@ -339,9 +376,9 @@
{#if mode === "idle" && lockEnabled} {#if mode === "idle" && lockEnabled}
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)"> <div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
<div style="position:relative;width:96px;height:96px"> <div style="position:relative;width:{logoLockSize}px;height:{logoLockSize}px">
<div class="logo-glow"></div> <div class="logo-glow"></div>
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:96px;height:96px;border-radius:22px;display:block;position:relative" /> <img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoLockSize}px;height:{logoLockSize}px;border-radius:22px;display:block;position:relative" />
</div> </div>
<div class="pin-block"> <div class="pin-block">
<div class="pin-dots" class:pin-shake={pinShake}> <div class="pin-dots" class:pin-shake={pinShake}>
@@ -355,15 +392,15 @@
{:else if mode === "idle"} {:else if mode === "idle"}
<div style="z-index:1;display:flex;flex-direction:column;align-items:center"> <div style="z-index:1;display:flex;flex-direction:column;align-items:center">
<div style="position:relative;width:128px;height:128px;margin-bottom:32px"> <div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
<div class="logo-glow"></div> <div class="logo-glow"></div>
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:128px;height:128px;border-radius:28px;display:block;position:relative" /> <img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoIdleSize}px;height:{logoIdleSize}px;border-radius:28px;display:block;position:relative" />
</div> </div>
<p class="hint">press any key to continue</p> <p class="hint">press any key to continue</p>
</div> </div>
{:else} {:else}
<div style="position:relative;width:140px;height:140px;margin-bottom:20px;z-index:1"> <div style="position:relative;width:{logoLoadingSize}px;height:{logoLoadingSize}px;margin-bottom:20px;z-index:1">
{#if !failed && !notConfigured} {#if !failed && !notConfigured}
<svg width={ringSize} height={ringSize} <svg width={ringSize} height={ringSize}
class="loading-ring" class="loading-ring"
@@ -377,7 +414,7 @@
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" /> style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
</svg> </svg>
{/if} {/if}
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" /> <img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block" />
</div> </div>
<p class="title-label">moku</p> <p class="title-label">moku</p>
@@ -385,12 +422,10 @@
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}> <div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
{#if failed || notConfigured} {#if failed || notConfigured}
<div class="error-box"> <div class="error-box">
<p class="error-label"> <p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
{failed ? "Could not reach server" : "Server not configured"}
</p>
<div class="error-actions"> <div class="error-actions">
<button class="err-btn" onclick={handleRetry}>Retry</button> <button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
<button class="err-btn err-btn--primary" onclick={handleBypass}>Enter app</button> <button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
</div> </div>
</div> </div>
{:else} {:else}
@@ -415,16 +450,20 @@
<style> <style>
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; } .splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; } .splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } } @keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } } @keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } } @keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } } @keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; } .logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; } .logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; } .hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; } .title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; } .error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; }
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; } .error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
.error-actions { display: flex; gap: 6px; } .error-actions { display: flex; gap: 6px; }
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; } .err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
@@ -438,13 +477,13 @@
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; } .status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
.loading-ring { transition: opacity 0.5s ease; } .loading-ring { transition: opacity 0.5s ease; }
.ring-hide { opacity: 0; } .ring-hide { opacity: 0; }
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; } .pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; } .pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; } .pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
.pin-dots { display: flex; gap: 12px; align-items: center; } .pin-dots { display: flex; gap: 12px; align-items: center; }
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; } .pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
.pin-dot-filled { background: var(--accent); border-color: var(--accent); } .pin-dot-filled { background: var(--accent); border-color: var(--accent); }
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
.pin-shake { animation: pinShake 0.42s ease; } .pin-shake { animation: pinShake 0.42s ease; }
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; } .pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
</style> </style>
@@ -2,18 +2,42 @@
import { store, dismissToast } from "../../store/state.svelte"; import { store, dismissToast } from "../../store/state.svelte";
import type { Toast } from "../../store/state.svelte"; import type { Toast } from "../../store/state.svelte";
const EXIT_MS = 280;
const leaving = new Set<string>();
const timers = new Map<string, ReturnType<typeof setTimeout>>(); const timers = new Map<string, ReturnType<typeof setTimeout>>();
function schedule(t: Toast) { function schedule(t: Toast) {
if (timers.has(t.id)) return; if (timers.has(t.id)) return;
const dur = t.duration ?? 3500; const dur = t.duration ?? 3500;
if (dur === 0) return; if (dur === 0) return;
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur)); timers.set(t.id, setTimeout(() => dismiss(t.id), dur));
}
function dismiss(id: string) {
if (leaving.has(id)) return;
leaving.add(id);
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id); }
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`);
if (!el) { finalize(id); return; }
const h = el.offsetHeight;
el.style.setProperty("--exit-h", `${h}px`);
el.classList.add("leaving");
setTimeout(() => finalize(id), EXIT_MS);
}
function finalize(id: string) {
leaving.delete(id);
dismissToast(id);
} }
$effect(() => { $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> = {
@@ -27,10 +51,11 @@
{#if store.toasts.length} {#if store.toasts.length}
<div class="toaster" aria-live="polite"> <div class="toaster" aria-live="polite">
{#each store.toasts as t (t.id)} {#each store.toasts as t (t.id)}
<button <div
class="toast toast-{t.kind}"
role="alert" role="alert"
onclick={() => dismissToast(t.id)} class="toast toast-{t.kind}"
data-toast-id={t.id}
onclick={() => dismiss(t.id)}
> >
<div class="accent-bar"></div> <div class="accent-bar"></div>
<span class="icon"> <span class="icon">
@@ -41,9 +66,9 @@
</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>
</button>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -56,46 +81,62 @@
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;
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
transition: opacity 0.15s ease, transform 0.15s ease;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
color: inherit; color: inherit;
text-align: left; text-align: left;
will-change: transform, opacity;
animation: slideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.toast:hover {
border-color: var(--border-base);
box-shadow: 0 12px 40px rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.06) inset;
transform: translateX(-3px);
} }
.toast:hover { opacity: 0.85; transform: translateX(-2px); }
.toast:active { transform: translateX(0) scale(0.98); } .toast:active { transform: translateX(0) scale(0.98); }
:global(.toast.leaving) {
animation: slideOut 0.28s cubic-bezier(0.4, 0, 1, 1) forwards !important;
pointer-events: none;
}
@keyframes slideIn { @keyframes slideIn {
from { opacity: 0; transform: translateX(16px) scale(0.98); } from { opacity: 0; transform: translateX(20px) scale(0.96); }
to { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 1; transform: translateX(0) scale(1); }
} }
@keyframes slideOut {
0% { opacity: 1; transform: translateX(0) scale(1); max-height: var(--exit-h, 80px); margin-bottom: 0; }
40% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: var(--exit-h, 80px); margin-bottom: 0; }
100% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: 0; margin-bottom: -5px; }
}
.accent-bar { .accent-bar {
width: 3px; width: 3px;
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); }
@@ -120,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 {
@@ -129,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 {
@@ -137,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;
-415
View File
@@ -1,415 +0,0 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, 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";
// ── Constants ─────────────────────────────────────────────────────────────
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; // pages per source on All tab
const PAGES_GENRE = 2; // pages per source on genre tabs
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}`;
}
// ── Local component state ─────────────────────────────────────────────────
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) ?? []);
// ── Helpers ───────────────────────────────────────────────────────────────
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));
}
// Push results into the reactive grid immediately — no batch delay.
function pushToGrid(genre: string, incoming: Manga[]) {
const filtered = filterOut(incoming);
if (!filtered.length) return;
const cur = genreResults.get(genre) ?? [];
genreResults.set(genre, dedup([...cur, ...filtered]).slice(0, GRID_LIMIT));
genreResults = new Map(genreResults);
}
// ── Source fan-out ────────────────────────────────────────────────────────
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)) {
// Cache hit — no network call needed
mangas = store.discoverCache.get(key)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type, page, query },
ctrl.signal
).then(d => d.fetchSourceManga).catch(() => null);
if (!result || ctrl.signal.aborted) return;
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);
}
// Stop paging early if source is exhausted
if (!hasNextPage) return;
}
}, ctrl.signal);
}
// ── Tab switch ────────────────────────────────────────────────────────────
async function switchGenre(genre: string) {
if (currentGenre === genre) return;
activeCtrl?.abort();
currentGenre = genre;
const ctrl = new AbortController();
activeCtrl = ctrl;
if (genre === "All") {
// Already have results from this session — show instantly, re-fan in background
if ((genreResults.get("All") ?? []).length > 0) {
genreLoading = false;
fanOut("All", ctrl).catch(() => {});
return;
}
genreResults.set("All", []);
genreResults = new Map(genreResults);
genreLoading = true;
await fanOut("All", ctrl);
if (!ctrl.signal.aborted) genreLoading = false;
return;
}
// Genre tab: serve cached local results instantly, always fan out too
const localKey = `local|${genre}`;
if (store.discoverCache.has(localKey)) {
genreResults.set(genre, store.discoverCache.get(localKey)!);
genreResults = new Map(genreResults);
fanOut(genre, ctrl).catch(() => {});
return;
}
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;
}
}
// ── Refresh ───────────────────────────────────────────────────────────────
async function refresh() {
activeCtrl?.abort();
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset
genreResults = new Map();
refreshing = true;
genreLoading = true;
const genre = currentGenre;
currentGenre = "";
await new Promise(r => setTimeout(r, 20));
await switchGenre(genre);
refreshing = false;
}
// ── Initial load ──────────────────────────────────────────────────────────
function loadAll() {
loadingLib = true;
loadError = false;
// Already have a session grid — show it immediately
if ((genreResults.get("All") ?? []).length > 0) {
loadingLib = false;
}
// Refresh library ID set so newly-added manga get filtered out
cache.get(CACHE_KEYS.DISCOVER, () =>
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
).then(m => {
store.discoverLibraryIds = new Set(
dedupeMangaById(m).filter(x => x.inLibrary).map(x => x.id)
);
}).catch(e => { console.error(e); loadError = true; })
.finally(() => { loadingLib = false; });
// Load sources then kick off All tab fan-out (only if grid is empty)
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then(d => {
allSources = d.sources.nodes;
if ((currentGenre === "All" || currentGenre === "") &&
(genreResults.get("All") ?? []).length === 0) {
const ctrl = new AbortController();
activeCtrl = ctrl;
genreLoading = true;
fanOut("All", ctrl).then(() => {
if (!ctrl.signal.aborted) genreLoading = false;
}).catch(() => {});
}
})
.catch(console.error);
}
onDestroy(() => { activeCtrl?.abort(); });
loadAll();
// ── Context menu ──────────────────────────────────────────────────────────
function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
ctx = { x: e.clientX, y: e.clientY, manga: m };
if (!catsLoaded) {
catsLoaded = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
.catch(console.error);
}
}
function buildCtxItems(m: Manga): MenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: BookmarkSimple, disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => {
cache.clear(CACHE_KEYS.LIBRARY);
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">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
<div class="cover-gradient"></div>
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
<div class="card-footer">
<p class="card-title">{m.title}</p>
{#if m.source?.displayName}<p class="card-source">{m.source.displayName}</p>{/if}
</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 .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); }
.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>
+4 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte"; import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries"; import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { store, setActiveDownloads } from "../../store/state.svelte"; import { store, setActiveDownloads } from "../../store/state.svelte";
import type { DownloadStatus } from "../../lib/types"; import type { DownloadStatus } from "../../lib/types";
@@ -114,7 +115,7 @@
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}> <div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
{#if manga?.thumbnailUrl} {#if manga?.thumbnailUrl}
<div class="thumb"> <div class="thumb">
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga?.title} class="thumb-img" loading="lazy" decoding="async" /> <Thumbnail src={manga.thumbnailUrl} alt={manga?.title} class="thumb-img" />
</div> </div>
{/if} {/if}
<div class="info"> <div class="info">
@@ -165,7 +166,7 @@
.row.row-active { border-color: var(--accent-dim); } .row.row-active { border-color: var(--accent-dim); }
.row.row-removing { opacity: 0.4; pointer-events: none; } .row.row-removing { opacity: 0.4; pointer-events: none; }
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); } .thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
.thumb-img { width: 100%; height: 100%; object-fit: cover; } :global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; } .info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+32 -7
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte"; import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries"; import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
import { store } from "../../store/state.svelte"; import { store } from "../../store/state.svelte";
import type { Extension } from "../../lib/types"; import type { Extension } from "../../lib/types";
@@ -16,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);
@@ -99,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); }
@@ -120,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);
@@ -132,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}
@@ -143,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" : ""} />
@@ -154,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">
@@ -225,7 +246,7 @@
{@const hasVariants = variants.length > 0} {@const hasVariants = variants.length > 0}
<div class="group"> <div class="group">
<div class="row"> <div class="row">
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} /> <Thumbnail src={primary.iconUrl} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<div class="info"> <div class="info">
<span class="name">{base}</span> <span class="name">{base}</span>
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span> <span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
@@ -310,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); }
@@ -323,7 +348,7 @@
.group { display: flex; flex-direction: column; } .group { display: flex; flex-direction: column; }
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); } .row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.icon { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); } :global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+5 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte"; import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util"; import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
@@ -217,7 +218,7 @@
{#each visibleItems as m (m.id)} {#each visibleItems as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}> <button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if} {#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
</div> </div>
<p class="card-title">{m.title}</p> <p class="card-title">{m.title}</p>
@@ -247,10 +248,10 @@
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover .cover { filter: brightness(1.06); } .card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .card-title { color: var(--text-primary); } .card:hover .card-title { color: var(--text-primary); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); } .cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); } .in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); } .card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-skeleton { padding: 0; } .card-skeleton { padding: 0; }
+82 -59
View File
@@ -1,12 +1,15 @@
<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 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";
function timeAgo(ts: number): string { function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000); const diff = Date.now() - ts, m = Math.floor(diff / 60000);
@@ -33,51 +36,38 @@
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);
} }
// Re-fetch library and reset hero chapters whenever the reader closes, function resetAndReload() {
// so the hero reflects the latest-read chapter immediately.
$effect(() => {
const sessionId = store.readerSessionId;
if (sessionId === 0) return; // skip initial mount — onMount handles that
cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.LIBRARY);
loadingLibrary = true; loadingLibrary = true;
heroChapters = []; heroChapters = [];
heroAllChapters = []; heroAllChapters = [];
heroChaptersFor = null; heroChaptersFor = null;
loadLibrary(); loadLibrary();
}
$effect(() => {
if (store.navPage === "home") untrack(() => resetAndReload());
}); });
async function fetchExtraCompleted(library: Manga[], completed: Category | null) { $effect(() => {
const completedIds = completed?.mangas?.nodes.map(m => m.id) ?? []; const sessionId = store.readerSessionId;
const missingIds = completedIds.filter(id => !library.some(m => m.id === id)); if (sessionId === 0) return;
if (!missingIds.length) return; untrack(() => resetAndReload());
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>();
@@ -114,7 +104,21 @@
let activeIdx = $state(0); let activeIdx = $state(0);
const activeSlot = $derived(resolvedSlots[activeIdx]); const activeSlot = $derived(resolvedSlots[activeIdx]);
const heroThumb = $derived(activeSlot?.kind === "pinned" ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") : activeSlot?.kind === "continue" ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") : ""); const heroThumbSrc = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
);
let heroThumb = $state("");
$effect(() => {
const path = heroThumbSrc;
const mode = store.settings.serverAuthMode ?? "NONE";
if (!path) { heroThumb = ""; return; }
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
// Use tauri-plugin-http backed getBlobUrl which handles auth and bypasses CORS
getBlobUrl(thumbUrl(path))
.then(url => { heroThumb = url; })
.catch(() => { heroThumb = ""; });
});
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""); const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null); const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null); const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
@@ -142,7 +146,8 @@
$effect(() => { $effect(() => {
const id = heroMangaId; const id = heroMangaId;
if (id && id !== heroChaptersFor) untrack(() => loadHeroChapters(id)); void store.settings.mangaPrefs?.[id!];
if (id) untrack(() => loadHeroChapters(id));
}); });
async function loadHeroChapters(mangaId: number) { async function loadHeroChapters(mangaId: number) {
@@ -155,9 +160,10 @@
if (heroChaptersFor !== mangaId) return; if (heroChaptersFor !== mangaId) return;
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
heroAllChapters = all; heroAllChapters = all;
const lastReadIdx = heroEntry ? all.findIndex(c => c.id === heroEntry!.chapterId) : all.findLastIndex(c => c.isRead); const filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
const startIdx = Math.max(0, lastReadIdx); const startIdx = Math.max(0, lastReadIdx);
heroChapters = all.slice(startIdx, startIdx + 5); heroChapters = filtered.slice(startIdx, startIdx + 5);
} catch { heroChapters = []; heroAllChapters = []; } } catch { heroChapters = []; heroAllChapters = []; }
finally { loadingHeroChapters = false; } finally { loadingHeroChapters = false; }
} }
@@ -176,7 +182,9 @@
if (all.length) { if (all.length) {
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
store.activeManga = manga; store.activeManga = manga;
openReader(chapter, all); const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
const target = list.find(c => c.id === chapter.id) ?? list[0];
if (target) openReader(target, list);
} }
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; } } catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
finally { resuming = false; } finally { resuming = false; }
@@ -190,11 +198,12 @@
resuming = true; resuming = true;
try { try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId }); const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0]; const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
if (ch) { if (ch) {
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
openReader(ch, chapters); openReader(ch, list);
} }
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; } } catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
finally { resuming = false; } finally { resuming = false; }
@@ -203,11 +212,12 @@
async function resumeEntry(entry: HistoryEntry) { async function resumeEntry(entry: HistoryEntry) {
try { try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId }); const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0]; const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
if (ch) { if (ch) {
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
openReader(ch, chapters); openReader(ch, list);
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; } else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; } } catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
} }
@@ -225,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;
@@ -387,7 +406,7 @@
{#if recentHistory.length > 0} {#if recentHistory.length > 0}
{#each recentHistory as entry (entry.chapterId)} {#each recentHistory as entry (entry.chapterId)}
<button class="activity-row" onclick={() => resumeEntry(entry)}> <button class="activity-row" onclick={() => resumeEntry(entry)}>
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle} class="activity-thumb" loading="lazy" decoding="async" /> <Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="activity-thumb" />
<div class="activity-info"> <div class="activity-info">
<span class="activity-title">{entry.mangaTitle}</span> <span class="activity-title">{entry.mangaTitle}</span>
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span> <span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
@@ -421,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">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" /> <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>
@@ -457,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>
@@ -487,7 +509,7 @@
{:else} {:else}
{#each pickerResults as m (m.id)} {#each pickerResults as m (m.id)}
<button class="picker-row" onclick={() => pinManga(m)}> <button class="picker-row" onclick={() => pinManga(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="picker-thumb" loading="lazy" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="picker-thumb" />
<div class="picker-info"> <div class="picker-info">
<span class="picker-manga-title">{m.title}</span> <span class="picker-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if} {#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
@@ -577,7 +599,7 @@
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); } .activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.activity-row:hover .activity-play { opacity: 1; } .activity-row:hover .activity-play { opacity: 1; }
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); } :global(.activity-thumb) { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -590,14 +612,15 @@
.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; }
.mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); } .mini-card:hover :global(.mini-cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.mini-card:hover { will-change: transform; } .mini-card:hover { will-change: transform; }
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); } .mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
.mini-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; } :global(.mini-cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; } .mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; } .mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); } .mini-card-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); }
@@ -636,7 +659,7 @@
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; } .picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); } .picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.picker-row:hover { background: var(--bg-raised); } .picker-row:hover { background: var(--bg-raised); }
.picker-thumb { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; } :global(.picker-thumb) { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+203 -10
View File
@@ -1,14 +1,16 @@
<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";
const CARD_MIN_W = 130; const CARD_MIN_W = 130;
const CARD_GAP = 16; const CARD_GAP = 16;
@@ -274,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 {
@@ -281,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;
@@ -343,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 {
@@ -423,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) {
@@ -541,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);
@@ -555,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); }
} }
@@ -567,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);
@@ -578,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) },
@@ -609,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);
@@ -643,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);
}; };
@@ -738,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"
@@ -842,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">
@@ -920,7 +1103,7 @@
onpointerleave={onCardPointerLeave} onpointerleave={onCardPointerLeave}
> >
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" draggable="false" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" draggable="false" />
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if} {#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if} {#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
{#if selectMode} {#if selectMode}
@@ -990,14 +1173,17 @@
.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,
.filter-panel-wrap { position: relative; } .filter-panel-wrap { position: relative; }
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-overlay, #1a1a1a); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: 6px; box-shadow: 0 12px 36px rgba(0,0,0,0.55), 0 2px 8px rgba(0,0,0,0.3); animation: fadeIn 0.1s ease both; } .dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: fadeIn 0.1s ease both; }
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; } .panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
.panel-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); gap: var(--sp-2); } .panel-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); gap: var(--sp-2); }
.panel-item:hover { background: var(--bg-subtle, #202020); color: var(--text-primary); } .panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); } .panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.panel-item-active:hover { background: var(--accent-dim); } .panel-item-active:hover { background: var(--accent-dim); }
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; } .panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
@@ -1066,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>
+286 -125
View File
@@ -1,12 +1,97 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, untrack } from "svelte"; import { onDestroy, untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
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";
@@ -17,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;
@@ -26,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",
@@ -48,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() {
@@ -61,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) {
@@ -87,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 }[] = [];
@@ -128,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;
@@ -155,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;
} }
@@ -163,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) {
@@ -177,7 +248,6 @@
} }
`; `;
// Build GraphQL filter for local library query
function buildTagFilter( function buildTagFilter(
tags: string[], tags: string[],
mode: TagMode, mode: TagMode,
@@ -201,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 =
@@ -229,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";
@@ -248,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; });
@@ -275,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) => {
@@ -287,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) {
@@ -306,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;
@@ -314,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)
@@ -331,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;
@@ -363,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);
@@ -379,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;
@@ -390,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;
@@ -398,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;
@@ -414,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) {
@@ -497,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,
@@ -513,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([]);
@@ -539,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;
@@ -602,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>
@@ -645,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
@@ -662,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}
@@ -697,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"/>
@@ -710,71 +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={m.thumbnailUrl} alt={m.title} class="cover" />
<img src={thumbUrl(source.iconUrl)} alt={source.displayName} class="sourceIcon" <div class="srchGradient"></div>
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">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#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>
@@ -902,7 +1054,7 @@
{#each tag_mergedResults as m (m.id)} {#each tag_mergedResults as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}> <button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap"> <div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if} {#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div> </div>
<p class="cardTitle">{m.title}</p> <p class="cardTitle">{m.title}</p>
@@ -961,8 +1113,7 @@
<div class="splitList"> <div class="splitList">
{#each src_visibleSources as src (src.id)} {#each src_visibleSources as src (src.id)}
<button class="splitItem splitItemSource" class:splitItemActive={src_activeSource?.id === src.id} onclick={() => srcSelectSource(src)}> <button class="splitItem splitItemSource" class:splitItemActive={src_activeSource?.id === src.id} onclick={() => srcSelectSource(src)}>
<img src={thumbUrl(src.iconUrl)} alt="" class="splitSourceIcon" <Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span> <span class="splitItemLabel">{src.name}</span>
{#if src_selectedLang === "all"} {#if src_selectedLang === "all"}
<span class="sourceLang" style="margin-left:auto;margin-right:4px">{src.lang.toUpperCase()}</span> <span class="sourceLang" style="margin-left:auto;margin-right:4px">{src.lang.toUpperCase()}</span>
@@ -989,8 +1140,7 @@
{:else} {:else}
<div class="splitContentHeader"> <div class="splitContentHeader">
<div class="splitSourceTitle"> <div class="splitSourceTitle">
<img src={thumbUrl(src_activeSource.iconUrl)} alt="" class="splitSourceIcon" <Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitContentTitle">{src_activeSource.displayName}</span> <span class="splitContentTitle">{src_activeSource.displayName}</span>
{#if src_loadingBrowse} {#if src_loadingBrowse}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true"> <svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
@@ -1030,7 +1180,7 @@
{#each src_browseResults as m (m.id)} {#each src_browseResults as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}> <button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap"> <div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if} {#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div> </div>
<p class="cardTitle">{m.title}</p> <p class="cardTitle">{m.title}</p>
@@ -1107,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); }
@@ -1117,7 +1265,7 @@
.sourceSection { padding: var(--sp-1) var(--sp-4) var(--sp-3); border-bottom: 1px solid var(--border-dim); } .sourceSection { padding: var(--sp-1) var(--sp-4) var(--sp-3); border-bottom: 1px solid var(--border-dim); }
.sourceSection:last-child { border-bottom: none; } .sourceSection:last-child { border-bottom: none; }
.sourceHeader { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0; } .sourceHeader { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0; }
.sourceIcon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); } :global(.sourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.sourceName { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); } .sourceName { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); }
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; } .sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.resultCount { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .resultCount { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@@ -1125,10 +1273,10 @@
.sourceRow { display: flex; gap: var(--sp-3); overflow-x: auto; padding-bottom: var(--sp-1); scrollbar-width: none; } .sourceRow { display: flex; gap: var(--sp-3); overflow-x: auto; padding-bottom: var(--sp-1); scrollbar-width: none; }
.sourceRow::-webkit-scrollbar { display: none; } .sourceRow::-webkit-scrollbar { display: none; }
.card { display: flex; flex-direction: column; gap: var(--sp-2); cursor: pointer; flex-shrink: 0; width: 110px; text-align: left; background: none; border: none; padding: 0; } .card { display: flex; flex-direction: column; gap: var(--sp-2); cursor: pointer; flex-shrink: 0; width: 110px; text-align: left; background: none; border: none; padding: 0; }
.card:hover .cover { filter: brightness(1.06); } .card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .cardTitle { color: var(--text-primary); } .card:hover .cardTitle { color: var(--text-primary); }
.coverWrap { position: relative; width: 100%; aspect-ratio: 2 / 3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); } .coverWrap { position: relative; width: 100%; aspect-ratio: 2 / 3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); } :global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
.inLibBadge { position: absolute; bottom: var(--sp-1); right: var(--sp-1); background: var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: 9px; font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); } .inLibBadge { position: absolute; bottom: var(--sp-1); right: var(--sp-1); background: var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: 9px; font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); } .cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 110px; } .skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 110px; }
@@ -1162,7 +1310,7 @@
.splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; } .splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); } .splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; } .splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.splitSourceIcon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); } :global(.splitSourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.tagActiveBar { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; } .tagActiveBar { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
.tagPillRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0; } .tagPillRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0; }
.tagPill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); } .tagPill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
@@ -1193,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>
+483 -226
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte"; import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { import {
GET_ALL_TRACKER_RECORDS, GET_ALL_TRACKER_RECORDS,
UPDATE_TRACK, UPDATE_TRACK,
@@ -10,8 +11,6 @@
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte"; import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Tracker, TrackRecord } from "../../lib/types"; import type { Tracker, TrackRecord } from "../../lib/types";
// ── Types ──────────────────────────────────────────────────────────────────
interface TrackerWithRecords extends Tracker { interface TrackerWithRecords extends Tracker {
trackRecords: { nodes: TrackRecord[] }; trackRecords: { nodes: TrackRecord[] };
} }
@@ -20,26 +19,20 @@
tracker: Tracker; tracker: Tracker;
} }
// ── State ──────────────────────────────────────────────────────────────────
let trackers: TrackerWithRecords[] = $state([]); let trackers: TrackerWithRecords[] = $state([]);
let loading: boolean = $state(true); let loading: boolean = $state(true);
let error: string | null = $state(null); let error: string | null = $state(null);
// Filter/view state
let activeTrackerId: number | "all" = $state("all"); let activeTrackerId: number | "all" = $state("all");
let statusFilter: number | "all" = $state("all"); let statusFilter: number | "all" = $state("all");
let searchQuery: string = $state(""); let searchQuery: string = $state("");
let sortBy: "title" | "status" | "score" | "progress" = $state("title"); let sortBy: "title" | "status" | "score" | "progress" = $state("title");
// Mutation state
let updatingId: number | null = $state(null); let updatingId: number | null = $state(null);
let syncingId: number | null = $state(null); let syncingId: number | null = $state(null);
// Chapter editing: recordId → draft value
let editingChapter: number | null = $state(null); let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0); let chapterDraft: number = $state(0);
let confirmUnbindRecord: FlatRecord | null = $state(null);
// ── Load ───────────────────────────────────────────────────────────────────
async function load() { async function load() {
loading = true; error = null; loading = true; error = null;
@@ -55,15 +48,13 @@
$effect(() => { load(); }); $effect(() => { load(); });
// ── Derived data ───────────────────────────────────────────────────────────
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn)); const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
const allRecords: FlatRecord[] = $derived( const allRecords: FlatRecord[] = $derived(
loggedInTrackers.flatMap(t => loggedInTrackers.flatMap(t =>
t.trackRecords.nodes.map(r => ({ t.trackRecords.nodes.map(r => ({
...r, ...r,
trackerId: r.trackerId ?? t.id, // fallback in case field is missing trackerId: r.trackerId ?? t.id,
tracker: t as Tracker, tracker: t as Tracker,
})) }))
) )
@@ -71,14 +62,11 @@
const totalCount = $derived(allRecords.length); const totalCount = $derived(allRecords.length);
// Status options across active tracker
const statusOptions = $derived.by(() => { const statusOptions = $derived.by(() => {
if (activeTrackerId === "all") { if (activeTrackerId === "all") {
// Merge all statuses, dedupe by value+name
const seen = new Map<string, { value: number; name: string }>(); const seen = new Map<string, { value: number; name: string }>();
for (const t of loggedInTrackers) { for (const t of loggedInTrackers)
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s); for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
}
return [...seen.values()]; return [...seen.values()];
} }
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? []; return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
@@ -111,8 +99,6 @@
}); });
}); });
// ── Mutations ──────────────────────────────────────────────────────────────
async function updateStatus(record: FlatRecord, status: number) { async function updateStatus(record: FlatRecord, status: number) {
updatingId = record.id; updatingId = record.id;
try { try {
@@ -122,9 +108,7 @@
patchRecord(record.trackerId, res.updateTrack.trackRecord); patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally { updatingId = null; }
updatingId = null;
}
} }
async function updateScore(record: FlatRecord, scoreString: string) { async function updateScore(record: FlatRecord, scoreString: string) {
@@ -136,9 +120,7 @@
patchRecord(record.trackerId, res.updateTrack.trackRecord); patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally { updatingId = null; }
updatingId = null;
}
} }
async function syncRecord(record: FlatRecord) { async function syncRecord(record: FlatRecord) {
@@ -151,9 +133,7 @@
addToast({ kind: "success", title: "Synced from tracker" }); addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message }); addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { } finally { syncingId = null; }
syncingId = null;
}
} }
async function unbind(record: FlatRecord) { async function unbind(record: FlatRecord) {
@@ -169,9 +149,7 @@
addToast({ kind: "info", title: "Unlinked from " + record.tracker.name }); addToast({ kind: "info", title: "Unlinked from " + record.tracker.name });
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Unbind failed", body: e?.message }); addToast({ kind: "error", title: "Unbind failed", body: e?.message });
} finally { } finally { updatingId = null; }
updatingId = null;
}
} }
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) { function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
@@ -196,9 +174,7 @@
chapterDraft = record.lastChapterRead; chapterDraft = record.lastChapterRead;
} }
function cancelChapterEditor() { function cancelChapterEditor() { editingChapter = null; }
editingChapter = null;
}
async function submitChapter(record: FlatRecord) { async function submitChapter(record: FlatRecord) {
const val = Math.max(0, chapterDraft); const val = Math.max(0, chapterDraft);
@@ -212,15 +188,34 @@
patchRecord(record.trackerId, res.updateTrack.trackRecord); patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally { updatingId = null; }
updatingId = null;
} }
function requestUnbind(record: FlatRecord) {
confirmUnbindRecord = record;
}
function cancelUnbind() {
confirmUnbindRecord = null;
}
async function confirmAndUnbind() {
if (!confirmUnbindRecord) return;
const record = confirmUnbindRecord;
confirmUnbindRecord = null;
await unbind(record);
}
function scoreToStars(score: string | undefined, scores: string[] | undefined): number {
if (!score || !scores || scores.length === 0) return 0;
const idx = scores.indexOf(score);
if (idx < 0) return 0;
return Math.round((idx / (scores.length - 1)) * 5);
} }
</script> </script>
<div class="page"> <div class="page">
<!-- ── Header ──────────────────────────────────────────────────────────── -->
<div class="header"> <div class="header">
<div class="header-top"> <div class="header-top">
<h1 class="heading">Tracking</h1> <h1 class="heading">Tracking</h1>
@@ -231,7 +226,6 @@
</div> </div>
</div> </div>
<!-- Tracker filter tabs -->
{#if !loading && loggedInTrackers.length > 0} {#if !loading && loggedInTrackers.length > 0}
<div class="tracker-tabs"> <div class="tracker-tabs">
<button <button
@@ -249,14 +243,13 @@
class:tab-active={activeTrackerId === t.id} class:tab-active={activeTrackerId === t.id}
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }} onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
> >
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-tracker-icon" /> <Thumbnail src={t.icon} alt={t.name} class="tab-tracker-icon" />
{t.name} {t.name}
<span class="tab-count">{count}</span> <span class="tab-count">{count}</span>
</button> </button>
{/each} {/each}
</div> </div>
<!-- Filter + sort bar -->
<div class="filter-bar"> <div class="filter-bar">
<div class="search-wrap"> <div class="search-wrap">
<MagnifyingGlass size={12} weight="light" class="search-ico" /> <MagnifyingGlass size={12} weight="light" class="search-ico" />
@@ -266,7 +259,6 @@
bind:value={searchQuery} bind:value={searchQuery}
/> />
</div> </div>
<div class="filter-right"> <div class="filter-right">
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" /> <Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<select class="filter-select" bind:value={statusFilter} <select class="filter-select" bind:value={statusFilter}
@@ -279,25 +271,23 @@
<option value={s.value}>{s.name}</option> <option value={s.value}>{s.name}</option>
{/each} {/each}
</select> </select>
<select class="filter-select" bind:value={sortBy}> <select class="filter-select" bind:value={sortBy}>
<option value="title">Sort: Title</option> <option value="title">Title</option>
<option value="status">Sort: Status</option> <option value="status">Status</option>
<option value="score">Sort: Score</option> <option value="score">Score</option>
<option value="progress">Sort: Progress</option> <option value="progress">Progress</option>
</select> </select>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
<!-- ── Body ────────────────────────────────────────────────────────────── -->
<div class="page-body"> <div class="page-body">
{#if loading} {#if loading}
<div class="state-center"> <div class="state-center">
<CircleNotch size={22} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="state-label">Loading tracking data</span> <span class="state-label">Loading…</span>
</div> </div>
{:else if error} {:else if error}
@@ -309,19 +299,19 @@
{:else if loggedInTrackers.length === 0} {:else if loggedInTrackers.length === 0}
<div class="state-center"> <div class="state-center">
<p class="state-text">No trackers connected.</p> <p class="state-text">No trackers connected.</p>
<p class="state-hint">Go to Settings → Tracking to log in to AniList, MAL, or others.</p> <p class="state-hint">Go to Settings → Tracking to connect AniList, MAL, or others.</p>
</div> </div>
{:else if filtered.length === 0} {:else if filtered.length === 0}
<div class="state-center"> <div class="state-center">
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results match your filters." : "Nothing tracked yet."}</p> <p class="state-text">{searchQuery || statusFilter !== "all" ? "No results." : "Nothing tracked yet."}</p>
{#if searchQuery || statusFilter !== "all"} {#if searchQuery || statusFilter !== "all"}
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button> <button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="records-list"> <div class="records-grid">
{#each filtered as record (record.tracker.id + ":" + record.id)} {#each filtered as record (record.tracker.id + ":" + record.id)}
{@const tracker = record.tracker} {@const tracker = record.tracker}
{@const isBusy = updatingId === record.id} {@const isBusy = updatingId === record.id}
@@ -329,64 +319,74 @@
{@const progress = record.totalChapters > 0 {@const progress = record.totalChapters > 0
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100) ? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
: null} : null}
{@const stars = scoreToStars(record.displayScore, tracker.scores)}
{@const statusName = (tracker.statuses ?? []).find(s => s.value === record.status)?.name ?? "—"}
<div class="record-card" class:record-busy={isBusy}> <div class="record-card" class:record-busy={isBusy}>
<!-- Cover --> <div class="card-cover-wrap">
<div class="record-cover-wrap" role="button" tabindex="0" <div class="card-cover-region"
role="button" tabindex="0"
onclick={() => openManga(record)} onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)} onkeydown={(e) => e.key === "Enter" && openManga(record)}
title="Open in library"
> >
{#if record.manga?.thumbnailUrl} {#if record.manga?.thumbnailUrl}
<img src={thumbUrl(record.manga.thumbnailUrl)} alt={record.title} class="record-cover" loading="lazy" /> <Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="card-cover-img" />
{:else} {:else}
<div class="record-cover record-cover-empty"></div> <div class="card-cover-empty"></div>
{/if} {/if}
<!-- Tracker badge -->
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-badge" />
</div> </div>
<!-- Info --> <div class="card-top-actions">
<div class="record-body"> {#if record.private}
<div class="record-top"> <span class="card-badge-btn" title="Private"><Lock size={10} weight="fill" /></span>
<div class="record-titles" role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
<span class="record-title">{record.title}</span>
{#if record.manga?.title && record.manga.title !== record.title}
<span class="record-local-title">{record.manga.title}</span>
{/if}
</div>
<div class="record-header-actions">
{#if activeTrackerId === "all"}
<span class="record-tracker-label">
<img src={thumbUrl(record.tracker.icon)} alt={record.tracker.name} class="record-tracker-label-icon" />
{record.tracker.name}
</span>
{/if} {/if}
{#if isSyncing} {#if isSyncing}
<CircleNotch size={12} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <span class="card-badge-btn">
<CircleNotch size={10} weight="light" class="anim-spin" />
</span>
{:else} {:else}
<button class="card-icon-btn" title="Sync from tracker" onclick={() => syncRecord(record)}> <button class="card-badge-btn" title="Sync" onclick={() => syncRecord(record)}>
<ArrowsClockwise size={12} weight="light" /> <ArrowsClockwise size={10} weight="light" />
</button> </button>
{/if} {/if}
{#if record.remoteUrl} {#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-icon-btn" title="Open on {record.tracker.name}"> <a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-badge-btn" title="Open on {tracker.name}">
<ArrowSquareOut size={12} weight="light" /> <ArrowSquareOut size={10} weight="light" />
</a> </a>
{/if} {/if}
<button class="card-icon-btn danger" title="Unlink" onclick={() => unbind(record)} disabled={isBusy}> <button class="card-badge-btn danger" title="Unlink" onclick={() => requestUnbind(record)} disabled={isBusy}>
<X size={12} weight="bold" /> <X size={10} weight="bold" />
</button> </button>
</div> </div>
<div class="card-tracker-badge">
<Thumbnail src={tracker.icon} alt={tracker.name} class="tracker-badge-img" />
</div>
</div> </div>
<!-- Controls row --> <div class="card-footer">
<div class="record-controls"> <div class="card-stars">
{#each Array(5) as _, i}
<span class="star" class:star-filled={i < stars}>★</span>
{/each}
</div>
<div class="card-title-block"
role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
<span class="card-title">{record.title}</span>
{#if record.manga?.title && record.manga.title !== record.title}
<span class="card-local-title">{record.manga.title}</span>
{/if}
</div>
<div class="card-meta-row">
<select <select
class="record-select" class="status-pill"
value={record.status} value={record.status}
disabled={isBusy} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))} onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
@@ -397,7 +397,7 @@
</select> </select>
<select <select
class="record-select record-select-score" class="score-select"
value={record.displayScore} value={record.displayScore}
disabled={isBusy} disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)} onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
@@ -406,29 +406,18 @@
<option value={s}> {s}</option> <option value={s}> {s}</option>
{/each} {/each}
</select> </select>
{#if record.private}
<span class="private-badge" title="Private"><Lock size={10} weight="fill" /></span>
{/if}
</div> </div>
<!-- Progress / Chapter editor -->
{#if editingChapter === record.id} {#if editingChapter === record.id}
<div class="chapter-editor"> <div class="chapter-editor" role="presentation" onclick={(e) => e.stopPropagation()}>
<div class="chapter-editor-top"> <div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter read</span> <span class="chapter-editor-label">Chapter</span>
<div class="chapter-input-wrap"> <div class="chapter-input-wrap">
<input <input
type="number" type="number" class="chapter-input"
class="chapter-input" min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
min="0" step="0.5" bind:value={chapterDraft}
max={record.totalChapters > 0 ? record.totalChapters : undefined} onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
step="0.5"
bind:value={chapterDraft}
onkeydown={(e) => {
if (e.key === "Enter") submitChapter(record);
if (e.key === "Escape") cancelChapterEditor();
}}
use:focusEl use:focusEl
/> />
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
@@ -437,45 +426,39 @@
</div> </div>
</div> </div>
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
<input <input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
type="range"
class="chapter-slider"
min="0"
max={record.totalChapters}
step="1"
bind:value={chapterDraft}
/>
{/if} {/if}
<div class="chapter-editor-actions"> <div class="chapter-editor-actions">
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button> <button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
</div> </div>
</div> </div>
{:else if progress !== null}
<div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to edit"
>
<div class="progress-track">
<div class="progress-fill" style="width:{progress}%"></div>
</div>
<span class="progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
<span class="progress-edit-hint"></span>
</div>
{:else} {:else}
<div class="record-progress clickable no-total" role="button" tabindex="0" <div class="progress-block clickable"
role="button" tabindex="0"
onclick={() => openChapterEditor(record)} onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)} onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to set chapter" title="Click to edit chapter"
> >
<span class="progress-label"> <div class="progress-labels">
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="progress-text">
{#if progress !== null}
Ch.&nbsp;{record.lastChapterRead}&thinsp;/&thinsp;{record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch.&nbsp;{record.lastChapterRead}&nbsp;read
{:else}
Set chapter…
{/if}
</span> </span>
<span class="progress-edit-hint"></span> {#if progress !== null}
<span class="progress-pct">{Math.round(progress)}%</span>
{/if}
</div>
<div class="progress-track">
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
{/each} {/each}
@@ -485,151 +468,425 @@
</div> </div>
</div> </div>
<style> {#if confirmUnbindRecord}
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } {@const r = confirmUnbindRecord}
<div class="modal-backdrop" role="presentation" onclick={cancelUnbind}>
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="modal-icon">
<X size={18} weight="bold" />
</div>
<p class="modal-title">Unlink from {r.tracker.name}?</p>
<p class="modal-body">
<strong>{r.title}</strong> will be removed from your tracking list. This won't affect your progress on {r.tracker.name} itself.
</p>
<div class="modal-actions">
<button class="modal-cancel" onclick={cancelUnbind}>Cancel</button>
<button class="modal-confirm" onclick={confirmAndUnbind}>Unlink</button>
</div>
</div>
</div>
{/if}
/* ── Header ─────────────────────────────────────────────────────────────── */ <style>
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); } .page {
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); } display: flex; flex-direction: column; height: 100%; overflow: hidden;
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } animation: fadeIn 0.16s ease both;
}
.header {
flex-shrink: 0;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-base);
}
.header-top {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-6) var(--sp-3);
}
.heading {
font-family: var(--font-ui); font-size: var(--text-xs);
font-weight: var(--weight-normal); color: var(--text-faint);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.header-actions { display: flex; align-items: center; gap: var(--sp-2); } .header-actions { display: flex; align-items: center; gap: var(--sp-2); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: none; color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); } .icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
border: none; color: var(--text-faint); background: none;
cursor: pointer; transition: color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); } .icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Tracker tabs ───────────────────────────────────────────────────────── */ .tracker-tabs {
.tracker-tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none; } display: flex; align-items: center; gap: 1px;
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
}
.tracker-tabs::-webkit-scrollbar { display: none; } .tracker-tabs::-webkit-scrollbar { display: none; }
.tracker-tab { .tracker-tab {
display: flex; align-items: center; gap: var(--sp-2); display: flex; align-items: center; gap: var(--sp-2);
padding: 9px 10px 8px; padding: 9px 10px 8px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); background: none; border: none; letter-spacing: var(--tracking-wide); color: var(--text-faint);
border-bottom: 2px solid transparent; background: none; border: none; border-bottom: 2px solid transparent;
border-radius: 0; cursor: pointer; white-space: nowrap; cursor: pointer; white-space: nowrap; margin-bottom: -1px;
transition: color var(--t-base), border-color var(--t-base); transition: color var(--t-base), border-color var(--t-base);
margin-bottom: -1px;
} }
.tracker-tab:hover { color: var(--text-muted); } .tracker-tab:hover { color: var(--text-muted); }
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); } .tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
.tab-tracker-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; } :global(.tab-tracker-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
.tab-count { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; } .tab-count {
font-size: 10px; padding: 0 4px; border-radius: var(--radius-full);
background: var(--bg-overlay); color: var(--text-faint);
min-width: 16px; text-align: center; line-height: 16px;
}
.tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); } .tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
/* ── Filter bar ─────────────────────────────────────────────────────────── */ .filter-bar {
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); } display: flex; align-items: center; gap: var(--sp-3);
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px 10px; } padding: var(--sp-2) var(--sp-5);
border-top: 1px solid var(--border-dim);
}
.search-wrap {
display: flex; align-items: center; gap: var(--sp-2); flex: 1;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 4px 10px;
}
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; } :global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; } .filter-search {
flex: 1; background: none; border: none; outline: none;
font-size: var(--text-sm); color: var(--text-primary); min-width: 0;
}
.filter-search::placeholder { color: var(--text-faint); } .filter-search::placeholder { color: var(--text-faint); }
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; } .filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.filter-select { .filter-select {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs);
padding: 4px 24px 4px 8px; border-radius: var(--radius-sm); letter-spacing: var(--tracking-wide); padding: 4px 22px 4px 8px;
border: 1px solid var(--border-dim); background: var(--bg-raised); border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
color: var(--text-faint); outline: none; cursor: pointer; background: var(--bg-raised); color: var(--text-faint);
appearance: none; -webkit-appearance: none; outline: none; cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 7px center; background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base); transition: border-color var(--t-base), color var(--t-base);
} }
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); } .filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); } .filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
/* ── Body ───────────────────────────────────────────────────────────────── */ .page-body {
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } flex: 1; overflow-y: auto; padding: var(--sp-5);
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
}
/* ── States ─────────────────────────────────────────────────────────────── */ .state-center {
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; } display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: var(--sp-3); height: 100%;
padding: var(--sp-10); text-align: center;
}
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-text { font-size: var(--text-sm); color: var(--text-muted); } .state-text { font-size: var(--text-sm); color: var(--text-muted); }
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); } .state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .retry-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 14px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); } .retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
/* ── Records list ───────────────────────────────────────────────────────── */ .records-grid {
.records-list { display: flex; flex-direction: column; gap: 2px; } display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--sp-4);
align-content: start;
}
.record-card { .record-card {
display: flex; align-items: flex-start; gap: var(--sp-4); display: flex; flex-direction: column;
padding: var(--sp-3) var(--sp-3); border-radius: var(--radius-lg);
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: none; background: var(--bg-raised);
transition: background var(--t-fast), opacity var(--t-base); overflow: hidden;
transition: border-color var(--t-base), opacity var(--t-base), transform var(--t-base);
} }
.record-card:hover { background: var(--bg-raised); } .record-card:hover {
.record-busy { opacity: 0.4; pointer-events: none; } border-color: var(--border-strong);
transform: translateY(-2px);
}
.record-busy { opacity: 0.35; pointer-events: none; }
/* Cover */ .card-cover-wrap {
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; } position: relative;
.record-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; transition: opacity var(--t-fast); } aspect-ratio: 2 / 3;
.record-cover-empty { background: var(--bg-overlay); } flex-shrink: 0;
.record-cover-wrap:hover .record-cover { opacity: 0.75; } overflow: hidden;
.record-tracker-badge { position: absolute; bottom: -3px; right: -3px; width: 14px; height: 14px; border-radius: 2px; border: 1px solid var(--bg-base); object-fit: contain; background: var(--bg-raised); } background: var(--bg-overlay);
}
/* Body */ .card-cover-region {
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); } position: absolute; inset: 0;
.record-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); } cursor: pointer;
.record-titles { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; cursor: pointer; } }
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
.record-titles:hover .record-title { color: var(--accent-fg); }
.record-local-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.record-header-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
.record-tracker-label-icon { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.card-icon-btn:disabled { opacity: 0.3; cursor: default; }
/* Controls */ :global(.card-cover-img) {
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } width: 100%; height: 100%;
.record-select { object-fit: cover; display: block;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); transition: transform 0.35s ease, opacity 0.2s ease;
padding: 3px 22px 3px 7px; border-radius: var(--radius-sm); }
border: 1px solid transparent; background: var(--bg-overlay); .card-cover-wrap:hover :global(.card-cover-img) {
color: var(--text-faint); outline: none; cursor: pointer; transform: scale(1.04);
appearance: none; -webkit-appearance: none; opacity: 0.88;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); }
.card-cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
.card-stars {
display: flex; gap: 3px; align-items: center;
padding-bottom: 2px;
}
.star {
font-size: 15px; line-height: 1;
color: var(--border-strong);
transition: color var(--t-base);
}
.star-filled { color: #f5c518; }
.card-top-actions {
position: absolute; top: 6px; right: 6px; z-index: 2;
display: flex; gap: 2px;
opacity: 0;
transition: opacity var(--t-base);
}
.card-cover-wrap:hover .card-top-actions { opacity: 1; }
.card-badge-btn {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius-sm);
background: rgba(0,0,0,0.6); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.75); cursor: pointer;
text-decoration: none;
transition: background var(--t-base), color var(--t-base);
}
.card-badge-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
.card-badge-btn.danger:hover { background: rgba(180,40,40,0.7); color: #fff; }
.card-badge-btn:disabled { opacity: 0.3; cursor: default; }
.card-tracker-badge {
position: absolute; bottom: 9px; right: 9px; z-index: 2;
width: 22px; height: 22px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.35);
background: var(--bg-raised);
box-shadow: 0 2px 8px rgba(0,0,0,0.55);
overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
:global(.tracker-badge-img) {
width: 100%; height: 100%;
object-fit: contain; display: block;
}
/* ── Footer panel ───────────────────────────────────────────────────────── */
.card-footer {
display: flex; flex-direction: column; gap: 10px;
padding: 13px 13px 13px;
border-top: 1px solid var(--border-dim);
}
/* Title */
.card-title-block {
display: flex; flex-direction: column; gap: 3px;
cursor: pointer; min-width: 0;
}
.card-title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-secondary); line-height: 1.38;
display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;
transition: color var(--t-base);
}
.card-title-block:hover .card-title { color: var(--accent-fg); }
.card-local-title {
font-family: var(--font-ui); font-size: 11px; color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.card-meta-row {
display: flex; align-items: center; gap: var(--sp-1);
}
.status-pill {
flex: 1; min-width: 0;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 5px 20px 5px 9px;
border-radius: 999px;
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
outline: none; cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center; background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base); transition: border-color var(--t-base), color var(--t-base);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); } .status-pill:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.record-select:disabled { opacity: 0.35; cursor: default; } .status-pill:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); } .status-pill option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 86px; }
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
/* Progress */ .score-select {
.record-progress { display: flex; align-items: center; gap: var(--sp-3); } flex-shrink: 0; width: 58px;
.progress-track { flex: 1; height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; max-width: 140px; } font-family: var(--font-ui); font-size: var(--text-2xs);
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; } letter-spacing: var(--tracking-wide);
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; } padding: 5px 16px 5px 6px;
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); } border-radius: var(--radius-sm);
.record-progress.clickable:hover { background: var(--bg-overlay); } border: 1px solid var(--border-dim);
.record-progress.clickable:hover .progress-label { color: var(--text-muted); } background: var(--bg-overlay);
.progress-edit-hint { font-size: 10px; color: var(--text-faint); opacity: 0; transition: opacity var(--t-fast); } color: var(--text-faint);
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; } outline: none; cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 4px center;
transition: border-color var(--t-base), color var(--t-base);
}
.score-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.score-select:disabled { opacity: 0.35; cursor: default; }
.score-select option { background: var(--bg-surface); color: var(--text-secondary); }
/* Chapter editor */ .progress-block {
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); } display: flex; flex-direction: column; gap: 7px;
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); } }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .progress-block.clickable {
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); } cursor: pointer; border-radius: var(--radius-sm);
.chapter-input { width: 72px; background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; } padding: 4px 5px;
margin: 0 -5px;
transition: background var(--t-fast);
}
.progress-block.clickable:hover { background: var(--bg-overlay); }
.progress-labels {
display: flex; align-items: center; justify-content: space-between;
}
.progress-text {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-muted); letter-spacing: var(--tracking-wide);
}
.progress-pct {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
}
.progress-track {
height: 3px; background: var(--border-strong);
border-radius: var(--radius-full); overflow: hidden;
}
.progress-fill {
height: 100%; background: var(--accent);
border-radius: var(--radius-full); transition: width 0.3s ease;
}
.chapter-editor {
display: flex; flex-direction: column; gap: var(--sp-2);
padding: var(--sp-2); border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: var(--bg-surface);
}
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.chapter-editor-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-1); }
.chapter-input {
width: 58px; background: var(--bg-surface);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-primary); outline: none; text-align: center;
appearance: none; -moz-appearance: textfield;
}
.chapter-input:focus { border-color: var(--accent); } .chapter-input:focus { border-color: var(--accent); }
.chapter-input::-webkit-outer-spin-button, .chapter-input::-webkit-outer-spin-button,
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .chapter-total { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; } .chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; } .chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); } .chapter-save-btn {
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
padding: 3px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim); background: var(--accent-muted);
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
}
.chapter-save-btn:hover { filter: brightness(1.15); } .chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); } .chapter-cancel-btn {
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
padding: 3px 6px; border-radius: var(--radius-sm);
border: none; background: none; color: var(--text-faint);
cursor: pointer; transition: color var(--t-base);
}
.chapter-cancel-btn:hover { color: var(--text-muted); } .chapter-cancel-btn:hover { color: var(--text-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } .modal-backdrop {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.12s ease both;
}
.modal {
background: var(--bg-surface);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl, 14px);
padding: var(--sp-6, 24px);
width: 320px; max-width: calc(100vw - 32px);
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
animation: modalIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
}
.modal-icon {
width: 40px; height: 40px; border-radius: 50%;
background: var(--color-error-bg, rgba(200,50,50,0.12));
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.25));
color: var(--color-error, #e05252);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.modal-title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-primary); text-align: center; margin: 0;
}
.modal-body {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); text-align: center; line-height: 1.5;
margin: 0;
}
.modal-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.modal-actions {
display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1);
}
.modal-cancel {
flex: 1;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 8px 0; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
color: var(--text-muted); cursor: pointer;
transition: border-color var(--t-base), color var(--t-base);
}
.modal-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
.modal-confirm {
flex: 1;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 8px 0; border-radius: var(--radius-md);
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.3));
background: var(--color-error-bg, rgba(200,50,50,0.1));
color: var(--color-error, #e05252); cursor: pointer;
transition: filter var(--t-base), background var(--t-base);
}
.modal-confirm:hover { filter: brightness(1.2); background: var(--color-error-bg, rgba(200,50,50,0.18)); }
@keyframes modalIn {
from { opacity: 0; transform: scale(0.92) translateY(8px); }
to { opacity: 1; transform: none; }
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
</style> </style>
<script module> <script module>
+358 -126
View File
@@ -6,15 +6,19 @@
CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus, CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus,
Bookmark, BookOpen, MonitorPlay, MapPin, Check, Bookmark, BookOpen, MonitorPlay, MapPin, Check,
} from "phosphor-svelte"; } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl, plainThumbUrl } from "../../lib/client";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import { getBlobUrl, preloadBlobUrls } from "../../lib/imageCache";
import { store as appStore } from "../../store/state.svelte";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addMarker, removeMarker, updateMarker } from "../../store/state.svelte"; import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addMarker, removeMarker, updateMarker } 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;
@@ -34,20 +38,34 @@
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[]>>();
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> { const win = getCurrentWindow();
const useBlob = $derived((appStore.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
function resolveUrl(url: string, priority = 0): Promise<string> {
return useBlob ? getBlobUrl(url, priority) : Promise.resolve(url);
}
function fetchPages(chapterId: number, signal?: AbortSignal, priorityPage = 0): Promise<string[]> {
const cached = pageCache.get(chapterId); const cached = pageCache.get(chapterId);
if (cached) return Promise.resolve(cached); if (cached) return Promise.resolve(cached);
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!inflight.has(chapterId)) { if (!inflight.has(chapterId)) {
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => { .then(d => {
const urls = d.fetchChapterPages.pages.map(thumbUrl); const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
if (useBlob) {
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
}
pageCache.set(chapterId, urls); pageCache.set(chapterId, urls);
return urls; return urls;
}) })
.finally(() => inflight.delete(chapterId)); .finally(() => inflight.delete(chapterId));
inflight.set(chapterId, p); inflight.set(chapterId, p);
} }
const base = inflight.get(chapterId)!; const base = inflight.get(chapterId)!;
if (!signal) return base; if (!signal) return base;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -57,20 +75,19 @@
} }
const aspectCache = new Map<string, number>(); const aspectCache = new Map<string, number>();
function preloadImage(url: string) { new Image().src = url; }
function measureAspect(url: string): Promise<number> { function measureAspect(url: string): Promise<number> {
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!); if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
return new Promise(res => { return resolveUrl(url).then(src => new Promise(res => {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
aspectCache.set(url, r);
res(r);
};
img.onerror = () => res(0.67); img.onerror = () => res(0.67);
img.src = url; img.src = src;
}); }));
}
function preloadImage(url: string) {
resolveUrl(url).then(src => { new Image().src = src; }).catch(() => {});
} }
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; } interface StripChapter { chapterId: number; chapterName: string; urls: string[]; }
@@ -109,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([]);
@@ -121,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;
@@ -145,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);
@@ -161,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) : []
@@ -216,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) {
@@ -235,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;
@@ -254,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;
@@ -275,12 +309,14 @@
store.pageNumber = 1; store.pageNumber = 1;
try { try {
const urls = await fetchPages(id, ctrl.signal); 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(() => {});
} catch (e: any) { } catch (e: any) {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
error = e instanceof Error ? e.message : String(e); error = e instanceof Error ? e.message : String(e);
@@ -316,14 +352,31 @@
} }
}); });
$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;
if (!chId || style !== "longstrip") return; if (!chId || style !== "longstrip") return;
if (chId === store.activeChapter?.id) return; if (chId === store.activeChapter?.id) return;
const wasAppended = untrack(() => stripChapters.findIndex(c => c.chapterId === chId)) > 0; const wasAppended = untrack(() => stripChapters.findIndex(c => c.chapterId === chId)) > 0;
if (wasAppended) { untrack(() => { resumePage = 0; resumeVisible = false; }); return; } if (wasAppended) {
untrack(() => {
resumePage = 0; resumeVisible = false;
const prefs = getMangaPrefs();
if (prefs.downloadAhead > 0) {
const list = store.activeChapterList;
const idx = list.findIndex(c => c.id === chId);
if (idx >= 0) {
const toQueue = list
.slice(idx + 1, idx + 1 + prefs.downloadAhead)
.filter(c => !c.isDownloaded && !c.isRead)
.map(c => c.id);
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
}
}
});
return;
}
const bookmark = store.bookmarks.find(b => b.chapterId === chId); const bookmark = store.bookmarks.find(b => b.chapterId === chId);
if (bookmark && bookmark.pageNumber > 1) { if (bookmark && bookmark.pageNumber > 1) {
untrack(() => { untrack(() => {
@@ -440,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;
}); });
@@ -450,9 +503,22 @@
$effect(() => { $effect(() => {
const ahead = store.settings.preloadPages ?? 3; const ahead = store.settings.preloadPages ?? 3;
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) preloadImage(url); } const current = store.pageUrls[store.pageNumber - 1];
if (!current) return;
if (useBlob) {
getBlobUrl(current, 999);
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[];
const behind = store.pageUrls[store.pageNumber - 2];
preloadBlobUrls(upcoming, ahead);
if (behind) preloadBlobUrls([behind], 0);
} else {
for (let i = 1; i <= ahead; i++) {
const url = store.pageUrls[store.pageNumber - 1 + i];
if (url) preloadImage(url);
}
const behind = store.pageUrls[store.pageNumber - 2]; const behind = store.pageUrls[store.pageNumber - 2];
if (behind) preloadImage(behind); if (behind) preloadImage(behind);
}
}); });
$effect(() => { $effect(() => {
@@ -472,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);
@@ -479,6 +547,12 @@
} }
}); });
function getMangaPrefs() {
const mangaId = store.activeManga?.id;
if (!mangaId) return DEFAULT_MANGA_PREFS;
return { ...DEFAULT_MANGA_PREFS, ...(appStore.settings.mangaPrefs?.[mangaId] ?? {}) };
}
function markChapterRead(id: number) { function markChapterRead(id: number) {
if (markedRead.has(id)) return; if (markedRead.has(id)) return;
markedRead.add(id); markedRead.add(id);
@@ -493,9 +567,42 @@
} }
gql(MARK_CHAPTER_READ, { id, isRead: true }) gql(MARK_CHAPTER_READ, { id, isRead: true })
.then(() => { .then(() => {
if (store.activeManga) { const mangaId = store.activeManga?.id;
if (mangaId) {
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c); const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
checkAndMarkCompleted(store.activeManga.id, updated); checkAndMarkCompleted(mangaId, updated);
const prefs = getMangaPrefs();
if (prefs.deleteOnRead) {
const ch = store.activeChapterList.find(c => c.id === id);
if (ch?.isDownloaded) {
const delayMs = (prefs.deleteDelayHours ?? 0) * 60 * 60 * 1000;
const doDelete = () => gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [id] }).catch(console.error);
if (delayMs === 0) doDelete();
else setTimeout(doDelete, delayMs);
}
}
if (prefs.downloadAhead > 0) {
const list = store.activeChapterList;
const idx = list.findIndex(c => c.id === id);
if (idx >= 0) {
const toQueue = list
.slice(idx + 1, idx + 1 + prefs.downloadAhead)
.filter(c => !c.isDownloaded && !c.isRead)
.map(c => c.id);
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
}
}
if (prefs.maxKeepChapters > 0) {
const downloaded = store.activeChapterList
.filter(c => c.isDownloaded)
.sort((a, b) => a.sourceOrder - b.sourceOrder);
const excess = downloaded.slice(0, Math.max(0, downloaded.length - prefs.maxKeepChapters));
if (excess.length) gql(DELETE_DOWNLOADED_CHAPTERS, { ids: excess.map(c => c.id) }).catch(console.error);
}
} }
}) })
.catch(e => { markedRead.delete(id); console.error(e); }); .catch(e => { markedRead.delete(id); console.error(e); });
@@ -515,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); }
} }
} }
@@ -547,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; }
@@ -555,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);
@@ -600,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 });
} }
} }
@@ -621,6 +730,7 @@
markerOpen = !markerOpen; markerOpen = !markerOpen;
zoomOpen = false; zoomOpen = false;
dlOpen = false; dlOpen = false;
winOpen = false;
} }
function commitMarker() { function commitMarker() {
@@ -666,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(); }
@@ -729,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>) {
@@ -740,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();
}; };
}); });
@@ -917,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>
@@ -930,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" }); } }}
> >
@@ -948,47 +1173,49 @@
{#if style === "longstrip"} {#if style === "longstrip"}
{#each stripToRender as chunk} {#each stripToRender as chunk}
{#each chunk.urls as url, i} {#each chunk.urls as url, i}
<img {#await resolveUrl(url, chunk.urls.length - i)}
src={url} <img src="" alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
alt="{chunk.chapterName} Page {i + 1}" {:then src}
data-local-page={i + 1} <img {src} alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
data-chapter={chunk.chapterId} {/await}
data-total={chunk.urls.length}
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
loading={i < 5 ? "eager" : "lazy"}
decoding="async"
/>
{/each} {/each}
{/each} {/each}
<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}
<img <div class="inspect-wrap" style="transform:scale({inspectScale}) translate({inspectPanX / inspectScale}px,{inspectPanY / inspectScale}px)">
src={store.pageUrls[store.pageNumber - 1]} {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
alt="Page {store.pageNumber}" <img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
class={imgCls} {:then src}
decoding="async" <img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" {/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}
<img {#await resolveUrl(store.pageUrls[pg - 1], 999)}
src={store.pageUrls[pg - 1]} <img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
alt="Page {pg}" {:then src}
class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" <img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
decoding="async" {/await}
/>
{/each} {/each}
</div> </div>
{: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}
<img src={store.pageUrls[store.pageNumber - 1]} alt="Page {store.pageNumber}" class={imgCls} decoding="async" /> <div class="inspect-wrap" style="transform:scale({inspectScale}) translate({inspectPanX / inspectScale}px,{inspectPanY / inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{/await}
</div>
{/if} {/if}
</div> </div>
@@ -1001,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}
@@ -1137,13 +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-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--color-error); opacity: 0.55; transition: opacity var(--t-fast), background var(--t-fast); } .marker-color-row { display: flex; align-items: center; gap: var(--sp-2); }
.marker-delete-btn:hover { opacity: 1; background: var(--color-error-bg); } .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); }
.marker-color-row { display: flex; gap: 5px; }
.marker-swatch { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; padding: 6px 4px 5px; border-radius: var(--radius-md); border: 1px solid transparent; background: none; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.marker-swatch:hover { background: var(--bg-raised); }
.marker-swatch-active { background: var(--bg-overlay); border-color: var(--border-strong); }
.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); }
@@ -1158,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; }
@@ -1184,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; }
@@ -3,11 +3,8 @@
import { store, updateSettings } from "../../store/state.svelte"; import { store, updateSettings } from "../../store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte"; import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import type { MangaPrefs } from "../../store/state.svelte"; import type { MangaPrefs } from "../../store/state.svelte";
import type { Chapter } from "../../lib/types"; let { mangaId, onClose }: {
let { mangaId, chapters, onClose }: {
mangaId: number; mangaId: number;
chapters: Chapter[];
onClose: () => void; onClose: () => void;
} = $props(); } = $props();
@@ -28,11 +25,6 @@
}); });
} }
const scanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b))
);
const DOWNLOAD_AHEAD_OPTIONS = [ const DOWNLOAD_AHEAD_OPTIONS = [
{ value: 0, label: "Off" }, { value: 0, label: "Off" },
{ value: 2, label: "2" }, { value: 2, label: "2" },
@@ -196,33 +188,7 @@
</div> </div>
</div> </div>
{#if scanlators.length > 1}
<div class="divider"></div>
<p class="section-label">Scanlator</p>
<div class="auto-row auto-row-align-start">
<div class="auto-info">
<span class="auto-label">Preferred scanlator</span>
<span class="auto-desc">Prioritise this group's chapters in the list</span>
</div>
<div class="scanlator-list">
<button
class="auto-chip scanlator-chip"
class:auto-chip-on={!getPref("preferredScanlator")}
onclick={() => setPref("preferredScanlator", "")}
>Any</button>
{#each scanlators as s}
<button
class="auto-chip scanlator-chip"
class:auto-chip-on={getPref("preferredScanlator") === s}
onclick={() => setPref("preferredScanlator", getPref("preferredScanlator") === s ? "" : s)}
title={s}
>{s}</button>
{/each}
</div>
</div>
{/if}
</div> </div>
</div> </div>
@@ -300,10 +266,6 @@
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); } .auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* Scanlator list */
.scanlator-list { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; justify-content: flex-end; max-width: 220px; }
.scanlator-chip { max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style> </style>
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte"; import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
import { store } from "../../store/state.svelte"; import { store } from "../../store/state.svelte";
import type { Manga, Source, Chapter } from "../../lib/types"; import type { Manga, Source, Chapter } from "../../lib/types";
@@ -253,8 +254,7 @@
class="source-row" class="source-row"
class:source-row-active={selectedSource?.id === src.id} class:source-row-active={selectedSource?.id === src.id}
onclick={() => pickSource(src)}> onclick={() => pickSource(src)}>
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon" <Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<div class="source-info"> <div class="source-info">
<span class="source-name">{src.displayName}</span> <span class="source-name">{src.displayName}</span>
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span> <span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
@@ -272,8 +272,7 @@
<!-- Source context pill --> <!-- Source context pill -->
{#if selectedSource} {#if selectedSource}
<div class="search-context"> <div class="search-context">
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon" <Thumbnail src={selectedSource.iconUrl} alt="" class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="search-context-name">{selectedSource.displayName}</span> <span class="search-context-name">{selectedSource.displayName}</span>
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button> <button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
</div> </div>
@@ -316,7 +315,7 @@
onclick={() => selectMatch(m, similarity)} onclick={() => selectMatch(m, similarity)}
disabled={loadingMatchId !== null}> disabled={loadingMatchId !== null}>
<div class="result-cover-wrap"> <div class="result-cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="result-cover" />
</div> </div>
<div class="result-info"> <div class="result-info">
<span class="result-title">{m.title}</span> <span class="result-title">{m.title}</span>
@@ -350,7 +349,7 @@
<div class="confirm-row"> <div class="confirm-row">
<div class="confirm-manga"> <div class="confirm-manga">
<div class="confirm-cover-wrap"> <div class="confirm-cover-wrap">
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="confirm-cover" /> <Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="confirm-cover" />
</div> </div>
<p class="confirm-title">{manga.title}</p> <p class="confirm-title">{manga.title}</p>
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p> <p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
@@ -363,7 +362,7 @@
<div class="confirm-manga"> <div class="confirm-manga">
<div class="confirm-cover-wrap"> <div class="confirm-cover-wrap">
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} class="confirm-cover" /> <Thumbnail src={selectedMatch.manga.thumbnailUrl} alt={selectedMatch.manga.title} class="confirm-cover" />
</div> </div>
<p class="confirm-title">{selectedMatch.manga.title}</p> <p class="confirm-title">{selectedMatch.manga.title}</p>
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p> <p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
@@ -455,7 +454,7 @@
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); } .source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); } .source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
.source-icon { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); } :global(.source-icon) { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; } .source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@@ -477,7 +476,7 @@
/* Search step */ /* Search step */
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); } .search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; } .search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
.search-context-icon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; } :global(.search-context-icon) { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); } .search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); }
.search-context-change:hover { opacity: 0.75; } .search-context-change:hover { opacity: 0.75; }
@@ -495,7 +494,7 @@
.result-row:hover:not(:disabled) { background: var(--bg-raised); } .result-row:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.5; cursor: default; } .result-row:disabled { opacity: 0.5; cursor: default; }
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; } .result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
.result-cover { width: 100%; height: 100%; object-fit: cover; } :global(.result-cover) { width: 100%; height: 100%; object-fit: cover; }
.result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; } .result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-meta { display: flex; align-items: center; gap: var(--sp-2); } .result-meta { display: flex; align-items: center; gap: var(--sp-2); }
@@ -515,7 +514,7 @@
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); } .confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); }
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; } .confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; }
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); } .confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.confirm-cover { width: 100%; height: 100%; object-fit: cover; } :global(.confirm-cover) { width: 100%; height: 100%; object-fit: cover; }
.confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); } .confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); }
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; } .confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
.confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
@@ -1,19 +1,20 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin } from "phosphor-svelte"; import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin, Funnel, Check } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
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";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
import MigrateModal from "./MigrateModal.svelte"; import MigrateModal from "./MigrateModal.svelte";
import TrackingPanel from "../shared/TrackingPanel.svelte"; import TrackingPanel from "./TrackingPanel.svelte";
import AutomationPanel from "../shared/AutomationPanel.svelte"; import AutomationPanel from "./AutomationPanel.svelte";
import MarkersPanel from "../shared/MarkersPanel.svelte"; import MarkersPanel from "./MarkersPanel.svelte";
const CHAPTERS_PER_PAGE = 25; const CHAPTERS_PER_PAGE = 25;
const MANGA_TTL_MS = 5 * 60 * 1000; const MANGA_TTL_MS = 5 * 60 * 1000;
@@ -57,6 +58,7 @@
let loadingLinkList: boolean = $state(false); let loadingLinkList: boolean = $state(false);
let selectedIds: Set<number> = $state(new Set()); let selectedIds: Set<number> = $state(new Set());
let sortMenuOpen: boolean = $state(false); let sortMenuOpen: boolean = $state(false);
let scanFilterOpen: boolean = $state(false);
let dlDropRef: HTMLDivElement | undefined = $state(); let dlDropRef: HTMLDivElement | undefined = $state();
let folderPickerRef: HTMLDivElement | undefined = $state(); let folderPickerRef: HTMLDivElement | undefined = $state();
let mangaAbort: AbortController | null = null; let mangaAbort: AbortController | null = null;
@@ -75,16 +77,71 @@
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K]; return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
} }
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
const id = store.activeManga?.id;
if (!id) return;
updateSettings({
mangaPrefs: {
...store.settings.mangaPrefs,
[id]: { ...(store.settings.mangaPrefs?.[id] ?? {}), [key]: value },
},
});
}
const hasSelection = $derived(selectedIds.size > 0); const hasSelection = $derived(selectedIds.size > 0);
const sortDir = $derived(store.settings.chapterSortDir); const sortDir = $derived(store.settings.chapterSortDir);
const sortMode = $derived(store.settings.chapterSortMode ?? "source"); const sortMode = $derived(store.settings.chapterSortMode ?? "source");
const availableScanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b))
);
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(() => {
const 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);
const preferred = getPref("preferredScanlator");
if (preferred) {
const pref: Chapter[] = [], rest: Chapter[] = [];
for (const c of base) (c.scanlator === preferred ? pref : rest).push(c);
base = [...pref, ...rest];
}
if (scanlatorFilter.length > 0) {
const seen = new Map<number, Chapter>();
for (const ch of base) {
const existing = seen.get(ch.chapterNumber);
if (!existing) {
if (!scanlatorForce || scanlatorFilter.includes(ch.scanlator ?? "")) {
seen.set(ch.chapterNumber, ch);
}
} else {
const np = scanlatorFilter.indexOf(ch.scanlator ?? "");
const op = scanlatorFilter.indexOf(existing.scanlator ?? "");
if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch);
}
}
base = [...seen.values()];
if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.chapterNumber);
else if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
}
return sortDir === "desc" ? base.reverse() : base; return sortDir === "desc" ? base.reverse() : base;
}); });
@@ -100,14 +157,31 @@
const hasFolders = $derived(assignedFolders.length > 0); const hasFolders = $derived(assignedFolders.length > 0);
const continueChapter = $derived((() => { const continueChapter = $derived((() => {
if (!chapters.length) return null; if (!sortedChapters.length) return null;
const asc = [...chapters].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(() => {
@@ -188,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) {
@@ -259,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;
@@ -284,6 +361,15 @@
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; } function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } } function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
$effect(() => {
if (!scanFilterOpen) return;
function onOutside(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".scan-filter-wrap")) scanFilterOpen = false;
}
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
return () => document.removeEventListener("mousedown", onOutside, true);
});
async function toggleLibrary() { async function toggleLibrary() {
if (!manga) return; if (!manga) return;
togglingLibrary = true; togglingLibrary = true;
@@ -321,7 +407,8 @@
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error); await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c); chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); } if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead && getPref("deleteOnRead")) { if (isRead) {
if (getPref("deleteOnRead")) {
const ch = chapters.find(c => c.id === chapterId); const ch = chapters.find(c => c.id === chapterId);
if (ch?.isDownloaded) { if (ch?.isDownloaded) {
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000; const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
@@ -329,6 +416,15 @@
else setTimeout(() => deleteDownloaded(chapterId), delayMs); else setTimeout(() => deleteDownloaded(chapterId), delayMs);
} }
} }
const ahead = getPref("downloadAhead");
if (ahead > 0) {
const idx = sortedChapters.findIndex(c => c.id === chapterId);
if (idx >= 0) {
const toQueue = sortedChapters.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
if (toQueue.length) enqueueMultiple(toQueue);
}
}
}
} }
async function markBulk(ids: number[], isRead: boolean) { async function markBulk(ids: number[], isRead: boolean) {
@@ -444,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); }
@@ -459,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() {
@@ -505,7 +624,7 @@
</button> </button>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" /> <Thumbnail src={store.activeManga.thumbnailUrl} alt={store.activeManga.title} class="cover" />
</div> </div>
{#if loadingManga} {#if loadingManga}
@@ -525,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}>
@@ -542,11 +661,11 @@
<div class="cta-section"> <div class="cta-section">
{#if continueChapter} {#if continueChapter}
<button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, chaptersAsc)}> <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">
@@ -647,6 +766,7 @@
</div> </div>
{/if} {/if}
</div> </div>
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}> <button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if} {#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
</button> </button>
@@ -670,6 +790,71 @@
{/if} {/if}
</div> </div>
{#if availableScanlators.length > 1}
<div class="scan-filter-wrap">
<button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
<Funnel size={14} weight={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0 ? "fill" : "light"} />
</button>
{#if scanFilterOpen}
<div class="scan-filter-panel" role="menu">
<div class="scan-filter-header">
<div class="scan-filter-tabs">
<button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === "prefer"} onclick={() => scanTab = "prefer"}>Prefer</button>
<button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === "block"} onclick={() => scanTab = "block"}>Block</button>
</div>
{#if scanTab === "prefer" && scanlatorFilter.length > 0}
<button class="scan-filter-clear" onclick={() => { setPref("scanlatorFilter", []); setPref("scanlatorForce", false); chapterPage = 1; }}>Clear</button>
{:else if scanTab === "block" && scanlatorBlacklist.length > 0}
<button class="scan-filter-clear" onclick={() => { setPref("scanlatorBlacklist", []); chapterPage = 1; }}>Clear</button>
{/if}
</div>
<div class="scan-filter-divider"></div>
{#if scanTab === "prefer"}
<div class="scan-filter-force-row">
<span class="scan-filter-force-label" title="Hide chapters with no preferred group match, rather than falling back to any available group.">Enforce</span>
<button class="scan-force-toggle" class:scan-force-on={scanlatorForce}
onclick={() => { setPref("scanlatorForce", !scanlatorForce); chapterPage = 1; }}>
{scanlatorForce ? "On" : "Off"}
</button>
</div>
<div class="scan-filter-divider"></div>
{#each availableScanlators as s}
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorFilter.includes(s)} role="menuitem"
onclick={() => {
const next = scanlatorFilter.includes(s)
? scanlatorFilter.filter(x => x !== s)
: [...scanlatorFilter, s];
setPref("scanlatorFilter", next);
chapterPage = 1;
}}>
<span class="scan-filter-check" class:scan-filter-check-on={scanlatorFilter.includes(s)}>
{#if scanlatorFilter.includes(s)}<Check size={9} weight="bold" />{/if}
</span>
{s}
</button>
{/each}
{:else}
{#each availableScanlators as s}
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorBlacklist.includes(s)} class:scan-filter-item-block={scanlatorBlacklist.includes(s)} role="menuitem"
onclick={() => {
const next = scanlatorBlacklist.includes(s)
? scanlatorBlacklist.filter(x => x !== s)
: [...scanlatorBlacklist, s];
setPref("scanlatorBlacklist", next);
chapterPage = 1;
}}>
<span class="scan-filter-check" class:scan-filter-check-block={scanlatorBlacklist.includes(s)}>
{#if scanlatorBlacklist.includes(s)}<X size={9} weight="bold" />{/if}
</span>
{s}
</button>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}> <button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} /> <ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button> </button>
@@ -787,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, chaptersAsc)} 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>
@@ -800,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, chaptersAsc)} 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, chaptersAsc))} 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}
@@ -818,8 +1004,10 @@
<div class="ch-right"> <div class="ch-right">
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if} {#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.isDownloaded} {#if ch.isDownloaded}
<span class="ch-dl-dot" title="Downloaded"></span> <div class="ch-dl-wrap">
<Download size={13} weight="fill" class="ch-dl-icon" />
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }} title="Delete download"><Trash size={13} weight="light" /></button> <button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }} title="Delete download"><Trash size={13} weight="light" /></button>
</div>
{:else if enqueueing.has(ch.id)} {:else if enqueueing.has(ch.id)}
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" /> <CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
{:else} {:else}
@@ -879,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>
@@ -892,7 +1080,7 @@
{#each linkPickerResults as m (m.id)} {#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)} {@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}> <button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
<div class="link-info"> <div class="link-info">
<span class="link-manga-title">{m.title}</span> <span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if} {#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
@@ -916,7 +1104,7 @@
.back:hover { color: var(--text-secondary); } .back:hover { color: var(--text-secondary); }
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; } .cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
.cover { width: 100%; height: 100%; object-fit: cover; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; }
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); } .meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-line { border-radius: var(--radius-sm); } .sk-line { border-radius: var(--radius-sm); }
@@ -980,7 +1168,7 @@
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); } .link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.link-row:hover { background: var(--bg-raised); } .link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; } .link-row-linked { background: var(--accent-muted) !important; }
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); } :global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@@ -1110,8 +1298,11 @@
.ch-row:hover .dl-btn-delete { opacity: 1; } .ch-row:hover .dl-btn-delete { opacity: 1; }
.dl-btn-delete:hover { background: var(--color-error-bg) !important; } .dl-btn-delete:hover { background: var(--color-error-bg) !important; }
.ch-dl-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-fg); flex-shrink: 0; opacity: 0.7; transition: opacity var(--t-fast); } .ch-dl-wrap { position: relative; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; }
.ch-row:hover .ch-dl-dot { opacity: 0; } :global(.ch-dl-icon) { color: var(--text-faint); transition: opacity var(--t-fast); }
.ch-row:hover .ch-dl-wrap :global(.ch-dl-icon) { opacity: 0; }
.ch-dl-wrap .dl-btn-delete { position: absolute; inset: 0; opacity: 0; }
.ch-row:hover .ch-dl-wrap .dl-btn-delete { opacity: 1; }
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; } .grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); } .grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
@@ -1122,4 +1313,30 @@
.markers-panel-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: stretch; justify-content: flex-start; animation: fadeIn 0.12s ease both; } .markers-panel-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: stretch; justify-content: flex-start; animation: fadeIn 0.12s ease both; }
.markers-panel-drawer { width: 280px; max-width: 90vw; background: var(--bg-surface); border-right: 1px solid var(--border-base); box-shadow: 4px 0 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both; } .markers-panel-drawer { width: 280px; max-width: 90vw; background: var(--bg-surface); border-right: 1px solid var(--border-base); box-shadow: 4px 0 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both; }
@keyframes drawerIn { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } } @keyframes drawerIn { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } }
.scan-filter-wrap { position: relative; }
.scan-filter-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
.scan-filter-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px 6px; }
.scan-filter-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium); }
.scan-filter-clear { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.scan-filter-clear:hover { color: var(--color-error); }
.scan-filter-divider { height: 1px; background: var(--border-dim); margin: 0 2px 4px; }
.scan-filter-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.scan-filter-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.scan-filter-item-active { color: var(--accent-fg); background: var(--accent-muted); }
.scan-filter-item-active:hover { background: var(--accent-dim); }
.scan-filter-tabs { display: flex; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px; }
.scan-filter-tab { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: 2px; border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
.scan-filter-tab:hover { color: var(--text-muted); }
.scan-filter-tab.scan-filter-tab-active { background: var(--bg-surface); color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
.scan-filter-force-row { display: flex; align-items: center; justify-content: space-between; padding: 5px 10px; }
.scan-filter-force-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); cursor: default; text-decoration: underline; text-decoration-style: dotted; text-decoration-color: var(--border-strong); text-underline-offset: 3px; }
.scan-force-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.scan-force-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
.scan-force-toggle.scan-force-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.scan-filter-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--bg-base); transition: background var(--t-base), border-color var(--t-base); }
.scan-filter-check-on { background: var(--accent); border-color: var(--accent); }
.scan-filter-check-block { background: var(--color-error); border-color: var(--color-error); }
.scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; }
.scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; }
</style> </style>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte"; import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { import {
GET_TRACKERS, GET_TRACKERS,
GET_MANGA_TRACK_RECORDS, GET_MANGA_TRACK_RECORDS,
@@ -260,7 +261,7 @@
class:tab-active={activeTab === t.id} class:tab-active={activeTab === t.id}
onclick={() => { activeTab = t.id; searchResults = []; }} onclick={() => { activeTab = t.id; searchResults = []; }}
> >
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-icon" /> <Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
{t.name} {t.name}
{#if rec}<span class="tab-dot"></span>{/if} {#if rec}<span class="tab-dot"></span>{/if}
</button> </button>
@@ -278,25 +279,50 @@
{#each records as record (record.id)} {#each records as record (record.id)}
{@const tracker = trackerFor(record.trackerId)} {@const tracker = trackerFor(record.trackerId)}
{@const isBusy = updatingRecord === record.id} {@const isBusy = updatingRecord === record.id}
<div class="record-row" class:record-busy={isBusy}> <div class="record-card" class:record-busy={isBusy}>
<div class="record-identity"> <!-- Title row -->
<div class="record-head">
<div class="record-source">
{#if tracker} {#if tracker}
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-icon" /> <Thumbnail src={tracker.icon} alt={tracker.name} class="record-tracker-icon" />
{/if} {/if}
<span class="record-source-name">{tracker?.name ?? "Tracker"}</span>
</div>
<div class="record-head-actions">
{#if tracker?.supportsPrivateTracking}
<button
class="record-icon-btn"
class:icon-active={record.private}
title={record.private ? "Private — click to make public" : "Public"}
disabled={isBusy}
onclick={() => togglePrivate(record)}
>
{#if record.private}<Lock size={11} weight="fill" />{:else}<LockOpen size={11} weight="light" />{/if}
</button>
{/if}
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button>
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
<X size={11} weight="bold" />
</button>
</div>
</div>
<!-- Linked title -->
{#if record.remoteUrl} {#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title"> <a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
{record.title} {record.title} <ArrowSquareOut size={10} weight="light" />
<ArrowSquareOut size={10} weight="light" />
</a> </a>
{:else} {:else}
<span class="record-title-plain">{record.title}</span> <span class="record-title-plain">{record.title}</span>
{/if} {/if}
</div>
<div class="record-controls"> <!-- Status + score row -->
<div class="record-selects">
<select <select
class="record-select" class="record-select record-select-status"
value={record.status} value={record.status}
disabled={isBusy} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))} onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
@@ -305,7 +331,6 @@
<option value={s.value}>{s.name}</option> <option value={s.value}>{s.name}</option>
{/each} {/each}
</select> </select>
<select <select
class="record-select record-select-score" class="record-select record-select-score"
value={record.displayScore} value={record.displayScore}
@@ -316,58 +341,19 @@
<option value={s}> {s}</option> <option value={s}> {s}</option>
{/each} {/each}
</select> </select>
{#if tracker?.supportsPrivateTracking}
<button
class="record-icon-btn"
class:icon-active={record.private}
title={record.private ? "Private — click to make public" : "Public — click to make private"}
disabled={isBusy}
onclick={() => togglePrivate(record)}
>
{#if record.private}
<Lock size={12} weight="fill" />
{:else}
<LockOpen size={12} weight="light" />
{/if}
</button>
{/if}
<button
class="record-icon-btn"
title="Sync from tracker"
disabled={syncing === record.id}
onclick={() => syncRecord(record)}
>
<ArrowsClockwise size={12} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button>
<button
class="record-icon-btn icon-danger"
title="Unlink"
disabled={isBusy}
onclick={() => unbind(record)}
>
<X size={12} weight="bold" />
</button>
</div> </div>
<!-- Chapter progress -->
{#if editingChapter === record.id} {#if editingChapter === record.id}
<div class="chapter-editor"> <div class="chapter-editor">
<div class="chapter-editor-top"> <div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter read</span> <span class="chapter-editor-label">Chapter read</span>
<div class="chapter-input-wrap"> <div class="chapter-input-wrap">
<input <input
type="number" type="number" class="chapter-input"
class="chapter-input" min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
min="0" step="0.5" bind:value={chapterDraft}
max={record.totalChapters > 0 ? record.totalChapters : undefined} onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
step="0.5"
bind:value={chapterDraft}
onkeydown={(e) => {
if (e.key === "Enter") submitChapter(record);
if (e.key === "Escape") cancelChapterEditor();
}}
use:autoFocus use:autoFocus
/> />
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
@@ -376,40 +362,36 @@
</div> </div>
</div> </div>
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
<input <input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
type="range"
class="chapter-slider"
min="0"
max={record.totalChapters}
step="1"
bind:value={chapterDraft}
/>
{/if} {/if}
<div class="chapter-editor-actions"> <div class="chapter-editor-actions">
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button> <button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
</div> <button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
</div>
{:else if record.totalChapters > 0}
<div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to edit"
>
<span class="record-progress-label">Ch. {record.lastChapterRead} / {record.totalChapters} <span class="edit-hint"></span></span>
<div class="record-progress-track">
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
</div> </div>
</div> </div>
{:else} {:else}
<div class="record-progress clickable" role="button" tabindex="0" <div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)} onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)} onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to set chapter" title="Click to edit"
> >
<div class="record-progress-header">
<span class="record-progress-label"> <span class="record-progress-label">
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="edit-hint"></span> {#if record.totalChapters > 0}
Ch. {record.lastChapterRead} / {record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch. {record.lastChapterRead} read
{:else}
Set chapter…
{/if}
</span> </span>
<span class="edit-hint">Edit</span>
</div>
{#if record.totalChapters > 0}
<div class="record-progress-track">
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
</div>
{/if}
</div> </div>
{/if} {/if}
@@ -540,60 +522,67 @@
} }
.tab:hover { color: var(--text-muted); } .tab:hover { color: var(--text-muted); }
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); } .tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
.tab-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; } :global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; } .tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); } .tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; } .tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
/* Records */ /* Records */
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-4); scrollbar-width: none; display: flex; flex-direction: column; gap: 2px; } .tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
.tab-body::-webkit-scrollbar { display: none; } .tab-body::-webkit-scrollbar { display: none; }
.record-row { .record-card {
display: flex; flex-direction: column; gap: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4); padding: var(--sp-4);
border-radius: var(--radius-md); border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised); background: var(--bg-raised);
transition: opacity var(--t-base); transition: opacity var(--t-base), border-color var(--t-base);
} }
.record-row:hover { background: var(--bg-overlay); } .record-card:hover { border-color: var(--border-strong); }
.record-busy { opacity: 0.45; pointer-events: none; } .record-busy { opacity: 0.4; pointer-events: none; }
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; } .record-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.record-tracker-icon { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.8; } .record-source { display: flex; align-items: center; gap: var(--sp-2); }
.record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); } :global(.record-tracker-icon) { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.75; }
.record-title:hover { opacity: 0.75; } .record-source-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .record-head-actions { display: flex; align-items: center; gap: 2px; }
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } .record-title { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); text-decoration: none; line-height: var(--leading-snug); transition: color var(--t-base); }
.record-title:hover { color: var(--accent-fg); }
.record-title-plain { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: var(--leading-snug); }
.record-selects { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.record-select { .record-select {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 22px 3px 8px; border-radius: var(--radius-sm); padding: 5px 24px 5px 10px; border-radius: var(--radius-md);
border: 1px solid transparent; background: var(--bg-overlay); border: 1px solid var(--border-dim); background: var(--bg-surface);
color: var(--text-faint); outline: none; cursor: pointer; color: var(--text-muted); outline: none; cursor: pointer; flex: 1; min-width: 0;
appearance: none; -webkit-appearance: none; appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center; background-repeat: no-repeat; background-position: right 8px center;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base); transition: border-color var(--t-base), color var(--t-base);
} }
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); } .record-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.record-select:focus { border-color: var(--border-strong); outline: none; color: var(--text-secondary); } .record-select:focus { border-color: var(--accent-dim); color: var(--text-secondary); }
.record-select:disabled { opacity: 0.35; cursor: default; } .record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); } .record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 90px; } .record-select-score { flex: 0 0 auto; min-width: 80px; }
.record-select-status { flex: 1; }
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; } .record-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); } .record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
.record-icon-btn.icon-active { color: var(--accent-fg); } .record-icon-btn.icon-active { color: var(--accent-fg); }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); } .record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.record-icon-btn:disabled { opacity: 0.3; cursor: default; } .record-icon-btn:disabled { opacity: 0.3; cursor: default; }
.record-progress { display: flex; flex-direction: column; gap: 4px; } .record-progress { display: flex; flex-direction: column; gap: 6px; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); } .record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 4px 6px; margin: -4px -6px; transition: background var(--t-fast); }
.record-progress.clickable:hover { background: var(--bg-overlay); } .record-progress.clickable:hover { background: var(--bg-overlay); }
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); display: flex; align-items: center; gap: var(--sp-1); } .record-progress-header { display: flex; align-items: center; justify-content: space-between; }
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); color: var(--text-faint); } .record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.record-progress.clickable:hover .edit-hint { opacity: 1; } .edit-hint { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); opacity: 0; transition: opacity var(--t-fast); }
.record-progress.clickable:hover .edit-hint { opacity: 0.6; }
.record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; } .record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; } .record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -147,7 +147,7 @@
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} />
<div class="te-backdrop" tabindex="-1" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}> <div class="te-backdrop" role="presentation" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<div <div
class="te-shell" class="te-shell"
role="dialog" role="dialog"
+76 -18
View File
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte"; import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_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);
@@ -86,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); } });
@@ -186,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) {
@@ -208,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];
@@ -221,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;
@@ -248,7 +289,7 @@
<div class="cover-col"> <div class="cover-col">
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" /> <Thumbnail src={store.previewManga.thumbnailUrl} alt={displayManga?.title} class="cover" />
{#if loadingDetail} {#if loadingDetail}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div> <div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if} {/if}
@@ -356,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}
@@ -386,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}
@@ -422,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">
@@ -437,7 +495,7 @@
{#each linkPickerResults as m (m.id)} {#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)} {@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}> <button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
<div class="link-info"> <div class="link-info">
<span class="link-manga-title">{m.title}</span> <span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if} {#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
@@ -462,7 +520,7 @@
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); } .modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; } .cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
.cover-wrap { position: relative; width: 100%; } .cover-wrap { position: relative; width: 100%; }
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; } :global(.cover) { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); } .cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); } .cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
@@ -548,7 +606,7 @@
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); } .link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.link-row:hover { background: var(--bg-raised); } .link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; } .link-row-linked { background: var(--accent-muted) !important; }
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); } :global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+5 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte"; import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte"; import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Manga, Category } from "../../lib/types"; import type { Manga, Category } from "../../lib/types";
@@ -120,7 +121,7 @@
<button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }} <button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
oncontextmenu={(e) => openCtx(e, m)}> oncontextmenu={(e) => openCtx(e, m)}>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if} {#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
</div> </div>
<p class="title">{m.title}</p> <p class="title">{m.title}</p>
@@ -165,10 +166,10 @@
.search:focus { border-color: var(--border-strong); } .search:focus { border-color: var(--border-strong); }
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; } .grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover .cover { filter: brightness(1.06); } .card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .title { color: var(--text-primary); } .card:hover .title { color: var(--text-primary); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); } .cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); } .in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); } .title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-skeleton { padding: 0; } .card-skeleton { padding: 0; }
+3 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte"; import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_SOURCES } from "../../lib/queries"; import { GET_SOURCES } from "../../lib/queries";
import { store } from "../../store/state.svelte"; import { store } from "../../store/state.svelte";
import type { Source } from "../../lib/types"; import type { Source } from "../../lib/types";
@@ -74,8 +75,7 @@
{@const open = expanded.has(g.name)} {@const open = expanded.has(g.name)}
<div> <div>
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}> <button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
<img src={thumbUrl(g.icon)} alt={g.name} class="icon" <Thumbnail src={g.icon} alt={g.name} class="icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
<div class="info"> <div class="info">
<span class="name">{g.name}</span> <span class="name">{g.name}</span>
<span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span> <span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span>
+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>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { thumbUrl, plainThumbUrl } from "../../lib/client";
import { store } from "../../store/state.svelte";
import { getBlobUrl } from "../../lib/imageCache";
let {
src,
alt = "",
class: cls = "",
loading = "lazy",
decoding = "async",
priority = 0,
onerror = undefined,
...rest
}: {
src: string;
alt?: string;
class?: string;
loading?: string;
decoding?: string;
priority?: number;
onerror?: ((e: Event) => void) | undefined;
[key: string]: any;
} = $props();
const isAuth = $derived(store.settings.serverAuthMode === "BASIC_AUTH");
let blobUrl = $state("");
$effect(() => {
if (!isAuth || !src) { blobUrl = ""; return; }
getBlobUrl(plainThumbUrl(src), priority)
.then(u => { blobUrl = u; })
.catch(() => { blobUrl = ""; });
});
const resolved = $derived(
isAuth
? (blobUrl || undefined)
: (src ? thumbUrl(src) : undefined)
);
</script>
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
+16 -22
View File
@@ -16,35 +16,34 @@ function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
} }
function buildRequestInit(init: RequestInit, extraHeaders: Record<string, string> = {}): RequestInit { export function fetchAuthenticated(
return {
...init,
credentials: "include",
headers: { ...(init.headers as Record<string, string> ?? {}), ...extraHeaders },
};
}
export async function fetchAuthenticated(
url: string, url: string,
init: RequestInit, init: RequestInit,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<Response> { ): Promise<Response> {
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings;
if (mode === "BASIC_AUTH") { if (mode === "BASIC_AUTH") {
const user = s.serverAuthUser?.trim() ?? ""; const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = s.serverAuthPass?.trim() ?? ""; const pass = store.settings.serverAuthPass?.trim() ?? "";
const headers = user && pass ? basicHeader(user, pass) : {}; return fetch(url, {
return fetch(url, buildRequestInit({ ...init, signal }, headers)); ...init,
signal,
credentials: "omit",
headers: {
...(init.headers as Record<string, string> ?? {}),
...(user && pass ? basicHeader(user, pass) : {}),
},
});
} }
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),
@@ -73,18 +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";
if (mode === "SIMPLE_LOGIN" || mode === "UI_LOGIN") {
updateSettings({ serverAuthMode: "NONE" });
}
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}`,
+34
View File
@@ -0,0 +1,34 @@
import type { Chapter } from "./types";
export function buildReaderChapterList(
chapters: Chapter[],
mangaPrefs: { preferredScanlator?: string; scanlatorFilter?: string[] } | undefined,
): Chapter[] {
const preferred = mangaPrefs?.preferredScanlator ?? "";
const filter = mangaPrefs?.scanlatorFilter ?? [];
let base = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
if (preferred) {
const pref: Chapter[] = [], rest: Chapter[] = [];
for (const c of base) (c.scanlator === preferred ? pref : rest).push(c);
base = [...pref, ...rest];
}
if (filter.length > 0) {
const seen = new Map<number, Chapter>();
for (const ch of base) {
const existing = seen.get(ch.chapterNumber);
if (!existing) {
seen.set(ch.chapterNumber, ch);
} else {
const np = filter.indexOf(ch.scanlator ?? "");
const op = filter.indexOf(existing.scanlator ?? "");
if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch);
}
}
base = [...seen.values()].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
return base;
}
+4 -15
View File
@@ -10,25 +10,14 @@ function getServerUrl(): string {
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; } function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
export function thumbUrl(path: string): string { export function plainThumbUrl(path: string): string {
if (!path) return ""; if (!path) return "";
if (path.startsWith("http")) return path; if (path.startsWith("http")) return path;
return `${getServerUrl()}${path}`;
const base = getServerUrl();
const mode = store.settings.serverAuthMode;
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
if (user && pass) {
const url = new URL(`${base}${path}`);
url.username = user;
url.password = pass;
return url.toString();
}
} }
return `${base}${path}`; export function thumbUrl(path: string): string {
return plainThumbUrl(path);
} }
interface GQLResponse<T> { interface GQLResponse<T> {
+48 -48
View File
@@ -1,79 +1,79 @@
import { start, stop, setActivity, clearActivity } from "tauri-plugin-drpc"; import { connect, disconnect, setActivity, clearActivity } from "tauri-plugin-discord-rpc-api";
import { Activity, Assets, Button, Timestamps } from "tauri-plugin-drpc/activity"; import { listen } from '@tauri-apps/api/event'
import type { Manga, Chapter } from "./types"; import type { Manga, Chapter } from './types'
const APP_ID = "1487894643613106298"; const APP_ID = '1487894643613106298'
const FALLBACK_IMAGE = "moku_logo"; const FALLBACK_IMAGE = 'moku_logo'
let sessionStart: number | null = null; let sessionStart: number | null = null
let unlisten: (() => void) | null = null
function isPublicUrl(url: string | null | undefined): boolean { function isPublicUrl(url: string | null | undefined): boolean {
return typeof url === "string" && url.startsWith("https://"); return typeof url === 'string' && url.startsWith('https://')
} }
function resolveCoverImage(manga: Manga): string { function resolveCoverImage(manga: Manga): string {
return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE; return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE
} }
function trunc(s: string, max = 128): string { function trunc(s: string, max = 128): string {
return s.length <= max ? s : `${s.slice(0, max - 1)}`; return s.length <= max ? s : `${s.slice(0, max - 1)}`
} }
function formatChapter(chapter: Chapter): string { function formatChapter(chapter: Chapter): string {
const n = chapter.chapterNumber; const n = chapter.chapterNumber
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`; return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`
}
function getTimestamps(): Timestamps {
return new Timestamps(sessionStart ?? Date.now());
} }
const BUTTONS = [ const BUTTONS = [
new Button("GitHub", "https://github.com/Youwes09/Moku"), { label: 'GitHub', url: 'https://github.com/Youwes09/Moku' },
new Button("Discord", "https://discord.gg/Jq3pwuNqPp"), { label: 'Discord', url: 'https://discord.gg/Jq3pwuNqPp' },
]; ]
export async function initRpc(): Promise<void> { export async function initRpc(): Promise<void> {
sessionStart = Date.now(); sessionStart = Date.now()
await start(APP_ID).catch(() => {});
unlisten = await listen('discord-rpc://running', ({ payload }) => {
if (payload) setIdle().catch(() => {})
})
await connect(APP_ID).catch(() => {})
} }
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> { export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
const assets = new Assets() await setActivity({
.setLargeImage(resolveCoverImage(manga)) details: trunc(manga.title),
.setLargeText(trunc(manga.title)) state: `${formatChapter(chapter)} · Reading`,
.setSmallImage(FALLBACK_IMAGE) timestamps: { start: sessionStart ?? Date.now() },
.setSmallText("Moku"); assets: {
largeImage: resolveCoverImage(manga),
const activity = new Activity() largeText: trunc(manga.title),
.setDetails(trunc(manga.title)) smallImage: FALLBACK_IMAGE,
.setState(`${formatChapter(chapter)} · Reading`) smallText: 'Moku',
.setAssets(assets) },
.setTimestamps(getTimestamps()); buttons: BUTTONS,
activity.setButton(BUTTONS); }).catch(() => {})
await setActivity(activity).catch(() => {});
} }
export async function setIdle(): Promise<void> { export async function setIdle(): Promise<void> {
const assets = new Assets() await setActivity({
.setLargeImage(FALLBACK_IMAGE) details: 'Browsing',
.setLargeText("Moku"); timestamps: { start: sessionStart ?? Date.now() },
assets: {
const activity = new Activity() largeImage: FALLBACK_IMAGE,
.setDetails("Browsing") largeText: 'Moku',
.setAssets(assets) },
.setTimestamps(getTimestamps()); buttons: BUTTONS,
activity.setButton(BUTTONS); }).catch(() => {})
await setActivity(activity).catch(() => {});
} }
export async function clearReading(): Promise<void> { export async function clearReading(): Promise<void> {
await clearActivity().catch(() => {}); await clearActivity().catch(() => {})
} }
export async function destroyRpc(): Promise<void> { export async function destroyRpc(): Promise<void> {
sessionStart = null; unlisten?.()
await stop().catch(() => {}); unlisten = null
sessionStart = null
await disconnect().catch(() => {})
} }
+104
View File
@@ -0,0 +1,104 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { store } from "../store/state.svelte";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6;
let active = 0;
interface QueueEntry {
url: string;
priority: number;
resolve: (v: string) => void;
reject: (e: unknown) => void;
}
const queue: QueueEntry[] = [];
function getAuthHeaders(): Record<string, string> {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
async function doFetch(url: string): Promise<string> {
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
if (!res.ok) throw new Error(`${res.status}`);
const blobUrl = URL.createObjectURL(await res.blob());
cache.set(url, 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() {
while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift()!;
active++;
doFetch(entry.url)
.then(entry.resolve, entry.reject)
.finally(() => {
inflight.delete(entry.url);
active--;
drain();
});
}
}
function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => {
insertSorted({ url, priority, resolve, reject });
});
inflight.set(url, promise);
drain();
return promise;
}
export function getBlobUrl(url: string, priority = 0): Promise<string> {
if (!url) return Promise.resolve("");
const cached = cache.get(url);
if (cached) return Promise.resolve(cached);
const existing = inflight.get(url);
if (existing) {
const idx = queue.findIndex(e => e.url === url);
if (idx !== -1 && priority > queue[idx].priority) {
const [entry] = queue.splice(idx, 1);
entry.priority = priority;
insertSorted(entry);
}
return existing;
}
return enqueue(url, priority);
}
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
urls.forEach((url, i) => {
if (!url || cache.has(url) || inflight.has(url)) return;
enqueue(url, basePriority - i);
});
}
export function revokeBlobUrl(url: string): void {
const blob = cache.get(url);
if (blob) {
URL.revokeObjectURL(blob);
cache.delete(url);
}
}
export function clearBlobCache(): void {
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
}
+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
}
}
}
}
`;
+122 -103
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 = {
@@ -190,6 +198,7 @@ export interface MangaPrefs {
pauseUpdates: boolean; pauseUpdates: boolean;
refreshInterval: "global" | "daily" | "weekly" | "manual"; refreshInterval: "global" | "daily" | "weekly" | "manual";
preferredScanlator: string; preferredScanlator: string;
scanlatorFilter: string[];
} }
export const DEFAULT_MANGA_PREFS: MangaPrefs = { export const DEFAULT_MANGA_PREFS: MangaPrefs = {
@@ -201,6 +210,7 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = {
pauseUpdates: false, pauseUpdates: false,
refreshInterval: "global", refreshInterval: "global",
preferredScanlator: "", preferredScanlator: "",
scanlatorFilter: [],
}; };
export interface Settings { export interface Settings {
@@ -261,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[];
@@ -332,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: [],
@@ -398,75 +410,70 @@ function mergeSettings(saved: any): Settings {
mangaPrefs: saved?.settings?.mangaPrefs ?? {}, mangaPrefs: saved?.settings?.mangaPrefs ?? {},
customThemes: saved?.settings?.customThemes ?? [], customThemes: saved?.settings?.customThemes ?? [],
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [], hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? DEFAULT_SETTINGS.nsfwFilteredTags,
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
libraryTabSort: saved?.settings?.libraryTabSort ?? {}, libraryTabSort: saved?.settings?.libraryTabSort ?? {},
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {}, libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {}, libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"], extraScanDirs: saved?.settings?.extraScanDirs ?? [],
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
}; };
} }
function mergeStats(saved: any): ReadingStats {
return { ...DEFAULT_READING_STATS, ...saved?.readingStats };
}
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const genId = () => Math.random().toString(36).slice(2, 10);
class Store { class Store {
navPage: NavPage = $state(saved?.navPage ?? "home");
libraryFilter: LibraryFilter = $state("library");
history: HistoryEntry[] = $state(saved?.history ?? []);
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
markers: MarkerEntry[] = $state(saved?.markers ?? []);
readingStats: ReadingStats = $state(mergeStats(saved));
settings: Settings = $state(mergeSettings(saved)); settings: Settings = $state(mergeSettings(saved));
readerSessionId: number = $state(0);
genreFilter: string = $state("");
searchPrefill: string = $state("");
activeManga: Manga | null = $state(null); activeManga: Manga | null = $state(null);
previewManga: Manga | null = $state(null); previewManga: Manga | null = $state(null);
activeSource: Source | null = $state(null);
pageUrls: string[] = $state([]);
pageNumber: number = $state(1);
libraryTagFilter: string[] = $state([]);
settingsOpen: boolean = $state(false);
activeDownloads: ActiveDownload[] = $state([]);
toasts: Toast[] = $state([]);
activeChapter: Chapter | null = $state(null); activeChapter: Chapter | null = $state(null);
activeChapterList: Chapter[] = $state([]); activeChapterList: Chapter[] = $state([]);
isFullscreen: boolean = $state(false); pageUrls: string[] = $state([]);
pageNumber: number = $state(1);
navPage: NavPage = $state("home");
libraryFilter: LibraryFilter = $state("all");
genreFilter: string = $state("");
searchPrefill: string = $state("");
toasts: Toast[] = $state([]);
categories: Category[] = $state([]); categories: Category[] = $state([]);
discoverCache: Map<string, Manga[]> = $state(new Map()); activeDownloads: ActiveDownload[] = $state([]);
discoverLibraryIds: Set<number> = $state(new Set()); activeSource: Source | null = $state(null);
discoverSrcOffset: number = $state(0); libraryTagFilter: string[] = $state([]);
settingsOpen: boolean = $state(false);
history: HistoryEntry[] = $state(saved?.history ?? []);
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
markers: MarkerEntry[] = $state(saved?.markers ?? []);
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
searchCache: Map<string, any> = $state(new Map());
searchLibraryIds: Set<number> = $state(new Set());
searchSrcOffset: number = $state(0);
readerSessionId: number = $state(0);
libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []);
lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0);
acknowledgedUpdates: Set<number> = $state(new Set(saved?.acknowledgedUpdateIds ?? []));
constructor() { constructor() {
$effect.root(() => { $effect.root(() => {
$effect(() => { persist({ storeVersion: STORE_VERSION }); }); $effect(() => {
$effect(() => { persist({ navPage: this.navPage }); }); persist({
$effect(() => { persist({ libraryFilter: this.libraryFilter }); }); settings: this.settings,
$effect(() => { persist({ history: this.history }); }); history: this.history,
$effect(() => { persist({ readLog: this.readLog }); }); bookmarks: this.bookmarks,
$effect(() => { persist({ bookmarks: this.bookmarks }); }); markers: this.markers,
$effect(() => { persist({ markers: this.markers }); }); readLog: this.readLog,
$effect(() => { persist({ readingStats: this.readingStats }); }); readingStats: this.readingStats,
$effect(() => { persist({ settings: this.settings }); }); libraryUpdates: this.libraryUpdates,
lastLibraryRefresh: this.lastLibraryRefresh,
acknowledgedUpdateIds: [...this.acknowledgedUpdates],
storeVersion: STORE_VERSION,
});
});
}); });
} }
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
if (manga) this.activeManga = manga;
this.activeChapter = chapter; this.activeChapter = chapter;
this.activeChapterList = chapterList; this.activeChapterList = chapterList;
this.pageUrls = []; if (manga !== undefined) this.activeManga = manga;
this.pageNumber = 1;
} }
closeReader() { closeReader() {
@@ -474,86 +481,70 @@ class Store {
this.activeChapterList = []; this.activeChapterList = [];
this.pageUrls = []; this.pageUrls = [];
this.pageNumber = 1; this.pageNumber = 1;
this.readerSessionId += 1;
} }
addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) { addHistory(entry: HistoryEntry, completed = false, minutes?: number) {
if (this.history[0]?.chapterId === entry.chapterId) { const filtered = this.history.filter(h => h.chapterId !== entry.chapterId);
this.history[0] = { ...this.history[0], readAt: entry.readAt }; this.history = [entry, ...filtered].slice(0, 500);
} else {
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
}
if (completed) { if (completed) {
const logEntry: ReadLogEntry = { const existing = this.readLog.find(e => e.chapterId === entry.chapterId);
mangaId: entry.mangaId, if (!existing) {
chapterId: entry.chapterId, const mins = minutes ?? AVG_MIN_PER_CHAPTER;
readAt: entry.readAt, this.readLog = [...this.readLog, { mangaId: entry.mangaId, chapterId: entry.chapterId, readAt: entry.readAt, minutes: mins }];
minutes, const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
}; const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
this.readLog = [...this.readLog, logEntry].slice(-5000); const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const lastDate = this.readingStats.lastStreakDate;
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().slice(0, 10);
let streak = this.readingStats.currentStreakDays;
if (lastDate === todayStr) {
} else if (lastDate === yesterdayStr) {
streak++;
} else {
streak = 1;
} }
const longest = Math.max(this.readingStats.longestStreakDays, streak);
const log = completed ? [...this.readLog] : this.readLog;
const uniqueChapters = new Set(log.map(e => e.chapterId));
const uniqueManga = new Set(log.map(e => e.mangaId));
const totalMinutes = log.reduce((sum, e) => sum + e.minutes, 0);
const today = todayStr();
let { currentStreakDays, longestStreakDays, lastStreakDate } = this.readingStats;
if (lastStreakDate !== today) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
lastStreakDate = today;
}
this.readingStats = { this.readingStats = {
totalChaptersRead: uniqueChapters.size, totalChaptersRead: uniqueChapters.size,
totalMangaRead: uniqueManga.size, totalMangaRead: uniqueManga.size,
totalMinutesRead: totalMinutes, totalMinutesRead: totalMinutes,
firstReadAt: this.readingStats.firstReadAt === 0 ? entry.readAt : this.readingStats.firstReadAt, firstReadAt: this.readingStats.firstReadAt || entry.readAt,
lastReadAt: entry.readAt, lastReadAt: entry.readAt,
currentStreakDays, currentStreakDays: streak,
longestStreakDays, longestStreakDays: longest,
lastStreakDate, lastStreakDate: todayStr,
}; };
} }
}
}
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label }; const filtered = this.bookmarks.filter(b => b.chapterId !== entry.chapterId);
this.bookmarks = [ this.bookmarks = [{ ...entry, savedAt: Date.now(), label }, ...filtered].slice(0, 200);
bookmark,
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
].slice(0, 200);
} }
removeBookmark(chapterId: number) { removeBookmark(chapterId: number) {
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId); this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
} }
clearBookmarks() { clearBookmarks() { this.bookmarks = []; }
this.bookmarks = [];
}
getBookmark(chapterId: number): BookmarkEntry | undefined { getBookmark(chapterId: number): BookmarkEntry | undefined {
return this.bookmarks.find(b => b.chapterId === chapterId); return this.bookmarks.find(b => b.chapterId === chapterId);
} }
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
const id = genId(); const id = Math.random().toString(36).slice(2);
const marker: MarkerEntry = { ...entry, id, createdAt: Date.now() }; this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
this.markers = [marker, ...this.markers].slice(0, 2000);
return id; return id;
} }
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
this.markers = this.markers.map(m => this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m);
m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m
);
} }
removeMarker(id: string) { removeMarker(id: string) {
@@ -667,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;
@@ -688,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++;
} }
} }
@@ -724,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(); }
@@ -746,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);
} }